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

feat: Add Time configtype #1905

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 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
8 changes: 7 additions & 1 deletion configtype/duration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Title: "CloudQuery configtype.Duration",
}
}
Expand Down
141 changes: 141 additions & 0 deletions configtype/time.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package configtype

import (
"encoding/json"
"fmt"
"regexp"
"time"

"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
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{1,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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using regular expressions can't we try to parse with each format, then return an error if all formats failed to parse?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, although we still need regexes for the jsonschema definitions

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the regexes let us give a more specific error, otherwise we'd always error with a duration parsing error (or whatever the last parsing case is).

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)
}

if err != nil {
return err
}

return nil
}

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{})
}
}

func (t Time) Time(now time.Time) time.Time {
switch t.typ {
case timeTypeFixed:
return t.time
case timeTypeRelative:
return now.Add(t.duration)
default:
return time.Time{}
}
}

func (t Time) IsRelative() bool {
return t.typ == timeTypeRelative
}

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
}

func (t Time) String() string {
switch t.typ {
case timeTypeFixed:
return t.time.String()
case timeTypeRelative:
return t.duration.String()
default:
return time.Time{}.String()
}
}
162 changes: 162 additions & 0 deletions configtype/time_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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) {
now, _ := time.Parse(time.RFC3339Nano, time.RFC3339Nano)

cases := []struct {
give string
want time.Time
}{
{"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)},
{"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)

var zeroTime configtype.Time

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), 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 {
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))
}
})
}
}
7 changes: 7 additions & 0 deletions configtype/util.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package configtype

import "strings"

func patternCases(cases ...string) string {
return "(" + strings.Join(cases, "|") + ")"
}
Loading