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 11 commits into
base: main
Choose a base branch
from
194 changes: 186 additions & 8 deletions configtype/duration.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,167 @@ package configtype

import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"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`
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(`^%[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
// 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 {
relative bool
sign int
duration time.Duration
days int
months int
years int
}

func NewDuration(d time.Duration) Duration {
return Duration{
sign: 1,
duration: d,
}
}

func ParseDuration(s string) (Duration, error) {
var d Duration

var inValue bool
var value int64

var inSign bool

parts := whitespaceRegexp.Split(s, -1)

var err error

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

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: `^[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+$`, // copied from time.ParseDuration
Pattern: patternCases(baseDurationPattern, humanDurationPattern, humanRelativeDurationPattern),
Title: "CloudQuery configtype.Duration",
}
}
Expand All @@ -34,22 +172,62 @@ 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.String())
}

func (d *Duration) Duration() time.Duration {
return d.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
}

func (d Duration) Equal(other Duration) bool {
return d.duration == other.duration
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 {
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, " ")
}
31 changes: 31 additions & 0 deletions configtype/duration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ 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},
}
for _, tc := range cases {
var d configtype.Duration
Expand All @@ -34,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
Expand Down
Loading