mirror of
https://github.com/open-policy-agent/opa.git
synced 2026-08-12 19:32:48 -06:00
Add support for days, weeks and years in parse_duration_ns (#8463)
Signed-off-by: Sebastian Spaink <sebastianspaink@gmail.com>
This commit is contained in:
@@ -24453,7 +24453,7 @@
|
||||
"time.parse_duration_ns": {
|
||||
"args": [
|
||||
{
|
||||
"description": "a duration like \"3m\"; see the [Go `time` package documentation](https://golang.org/pkg/time/#ParseDuration) for more details",
|
||||
"description": "a duration like \"3m\"; see the [OPA `Duration Parsing` documentation](https://www.openpolicyagent.org/docs/latest/policy-reference/builtins/time#duration-parsing) for more details",
|
||||
"name": "duration",
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,20 @@ Timezones can be specified as
|
||||
Note that OPA will use the `time/tzdata` data if none is present on the runtime filesystem (see the
|
||||
[Go `time.LoadLocation()`](https://pkg.go.dev/time#LoadLocation) documentation for more information).
|
||||
|
||||
#### Duration Parsing
|
||||
|
||||
OPA supports the following time units for `time.parse_duration_ns`:
|
||||
|
||||
* `ns` - NanoSeconds
|
||||
* `us` (or `µs`) - MicroSeconds
|
||||
* `ms` - MilliSeconds
|
||||
* `s` - Seconds
|
||||
* `m` - Minutes (ignoring leap seconds)
|
||||
* `h` - Hours
|
||||
* `d` - Days (ignoring so-called daylight saving time)
|
||||
* `w` - Weeks
|
||||
* `y` - Years (ignoring leap days)
|
||||
|
||||
#### Timestamp Parsing
|
||||
|
||||
OPA can parse timestamps of nearly arbitrary formats, and currently accepts the same inputs as Go's `time.Parse()` utility.
|
||||
|
||||
@@ -29,6 +29,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
//go:generate build/gen-run-go.sh github.com/mna/pigeon@v1.3.0 -o v1/topdown/durationparser/duration_parser.go v1/topdown/durationparser/duration.peg
|
||||
//go:generate build/gen-run-go.sh internal/cmd/genopacapabilities/main.go capabilities.json
|
||||
//go:generate build/gen-run-go.sh internal/cmd/genbuiltinmetadata/main.go builtin_metadata.json
|
||||
//go:generate build/gen-run-go.sh internal/cmd/genversionindex/main.go v1/ast/version_index.json
|
||||
|
||||
+1
-1
@@ -2416,7 +2416,7 @@ var ParseDurationNanos = &Builtin{
|
||||
Description: "Returns the duration in nanoseconds represented by a string.",
|
||||
Decl: types.NewFunction(
|
||||
types.Args(
|
||||
types.Named("duration", types.S).Description("a duration like \"3m\"; see the [Go `time` package documentation](https://golang.org/pkg/time/#ParseDuration) for more details"),
|
||||
types.Named("duration", types.S).Description("a duration like \"3m\"; see the [OPA `Duration Parsing` documentation](https://www.openpolicyagent.org/docs/latest/policy-reference/builtins/time#duration-parsing) for more details"),
|
||||
),
|
||||
types.Named("ns", types.N).Description("the `duration` in nanoseconds"),
|
||||
),
|
||||
|
||||
+109
-1
@@ -1,6 +1,30 @@
|
||||
---
|
||||
cases:
|
||||
- data: {}
|
||||
modules:
|
||||
- |
|
||||
package generated
|
||||
|
||||
p = ns {
|
||||
time.parse_duration_ns("100ns", ns)
|
||||
}
|
||||
note: time/parse duration nanos, nanoseconds
|
||||
query: data.generated.p = x
|
||||
want_result:
|
||||
- x: 100
|
||||
- data: { }
|
||||
modules:
|
||||
- |
|
||||
package generated
|
||||
|
||||
p = ns {
|
||||
time.parse_duration_ns("100us", ns)
|
||||
}
|
||||
note: time/parse duration nanos, microseconds
|
||||
query: data.generated.p = x
|
||||
want_result:
|
||||
- x: 100000
|
||||
- data: { }
|
||||
modules:
|
||||
- |
|
||||
package generated
|
||||
@@ -8,7 +32,91 @@ cases:
|
||||
p = ns {
|
||||
time.parse_duration_ns("100ms", ns)
|
||||
}
|
||||
note: time/parse duration nanos
|
||||
note: time/parse duration nanos, milliseconds
|
||||
query: data.generated.p = x
|
||||
want_result:
|
||||
- x: 100000000
|
||||
- data: { }
|
||||
modules:
|
||||
- |
|
||||
package generated
|
||||
|
||||
p = ns {
|
||||
time.parse_duration_ns("100s", ns)
|
||||
}
|
||||
note: time/parse duration nanos, seconds
|
||||
query: data.generated.p = x
|
||||
want_result:
|
||||
- x: 100000000000
|
||||
- data: { }
|
||||
modules:
|
||||
- |
|
||||
package generated
|
||||
|
||||
p = ns {
|
||||
time.parse_duration_ns("100m", ns)
|
||||
}
|
||||
note: time/parse duration nanos, minutes
|
||||
query: data.generated.p = x
|
||||
want_result:
|
||||
- x: 6000000000000
|
||||
- data: { }
|
||||
modules:
|
||||
- |
|
||||
package generated
|
||||
|
||||
p = ns {
|
||||
time.parse_duration_ns("100h", ns)
|
||||
}
|
||||
note: time/parse duration nanos, hours
|
||||
query: data.generated.p = x
|
||||
want_result:
|
||||
- x: 360000000000000
|
||||
- data: { }
|
||||
modules:
|
||||
- |
|
||||
package generated
|
||||
|
||||
p = ns {
|
||||
time.parse_duration_ns("365d", ns)
|
||||
}
|
||||
note: time/parse duration nanos, days
|
||||
query: data.generated.p = x
|
||||
want_result:
|
||||
- x: 31536000000000000
|
||||
- data: { }
|
||||
modules:
|
||||
- |
|
||||
package generated
|
||||
|
||||
p = ns {
|
||||
time.parse_duration_ns("1w", ns)
|
||||
}
|
||||
note: time/parse duration nanos, weeks
|
||||
query: data.generated.p = x
|
||||
want_result:
|
||||
- x: 604800000000000
|
||||
- data: { }
|
||||
modules:
|
||||
- |
|
||||
package generated
|
||||
|
||||
p = ns {
|
||||
time.parse_duration_ns("1y", ns)
|
||||
}
|
||||
note: time/parse duration nanos, years
|
||||
query: data.generated.p = x
|
||||
want_result:
|
||||
- x: 31536000000000000
|
||||
- data: { }
|
||||
modules:
|
||||
- |
|
||||
package generated
|
||||
|
||||
p = ns {
|
||||
time.parse_duration_ns("1d2h", ns)
|
||||
}
|
||||
note: time/parse duration nanos, multiple-units
|
||||
query: data.generated.p = x
|
||||
want_result:
|
||||
- x: 93600000000000
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
package durationparser
|
||||
}
|
||||
|
||||
Duration <- sign:Sign? segments:Segment+ EOF {
|
||||
signStr := ""
|
||||
if sign != nil {
|
||||
signStr = sign.(string)
|
||||
}
|
||||
raw := segments.([]any)
|
||||
segs := make([]Segment, len(raw))
|
||||
for i, s := range raw {
|
||||
segs[i] = s.(Segment)
|
||||
}
|
||||
return Result{Sign: signStr, Segments: segs}, nil
|
||||
}
|
||||
|
||||
Sign <- [-+] {
|
||||
return string(c.text), nil
|
||||
}
|
||||
|
||||
Segment <- digits:Digits unit:Unit {
|
||||
return Segment{Digits: digits.(string), Unit: unit.(string)}, nil
|
||||
}
|
||||
|
||||
Digits <- [0-9.]+ {
|
||||
return string(c.text), nil
|
||||
}
|
||||
|
||||
Unit <- ("ms" / "us" / "µs" / "ns" / [a-z]) {
|
||||
return string(c.text), nil
|
||||
}
|
||||
|
||||
EOF <- !.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
package durationparser
|
||||
|
||||
// Result holds the parsed components of a duration string.
|
||||
type Result struct {
|
||||
Sign string // "" or "-" or "+"
|
||||
Segments []Segment
|
||||
}
|
||||
|
||||
// Segment holds a single parsed segment (e.g. Digits="1.5", Unit="d").
|
||||
type Segment struct {
|
||||
Digits string
|
||||
Unit string
|
||||
}
|
||||
+86
-2
@@ -7,15 +7,18 @@ package topdown
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
_ "time/tzdata" // this is needed to have LoadLocation when no filesystem tzdata is available
|
||||
|
||||
"github.com/open-policy-agent/opa/v1/ast"
|
||||
"github.com/open-policy-agent/opa/v1/topdown/builtins"
|
||||
"github.com/open-policy-agent/opa/v1/topdown/durationparser"
|
||||
)
|
||||
|
||||
var tzCache map[string]*time.Location
|
||||
@@ -27,6 +30,84 @@ var minDateAllowedForNsConversion = time.Unix(0, math.MinInt64)
|
||||
// 2262-04-11T23:47:16.854775807-00:00
|
||||
var maxDateAllowedForNsConversion = time.Unix(0, math.MaxInt64)
|
||||
|
||||
var durationCoefficients = map[string]int{
|
||||
"d": 24,
|
||||
"w": 7 * 24,
|
||||
"y": 365 * 24,
|
||||
}
|
||||
|
||||
// parseExtendedDuration parses a duration string that may contain extended
|
||||
// units (d, w, y) mixed with standard Go duration units (h, m, s, ms, us, ns).
|
||||
// Extended unit segments are rewritten to equivalent hours (e.g. "1d2h30m" → "24h2h30m")
|
||||
func parseExtendedDuration(s string) (int64, error) {
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("time: invalid duration %q", s)
|
||||
}
|
||||
|
||||
if !strings.ContainsAny(s, "dwy") {
|
||||
v, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(v), nil
|
||||
}
|
||||
|
||||
result, err := durationparser.Parse("", []byte(s))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("time: invalid duration %q", s)
|
||||
}
|
||||
|
||||
rewritten, err := rewriteDuration(result.(durationparser.Result))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("time: invalid duration %q", s)
|
||||
}
|
||||
|
||||
v, err := time.ParseDuration(rewritten)
|
||||
if err != nil {
|
||||
// Replace the rewritten duration in the error with the original.
|
||||
msg := err.Error()
|
||||
if i := strings.LastIndex(msg, " "); i != -1 {
|
||||
msg = msg[:i]
|
||||
}
|
||||
return 0, fmt.Errorf("%s %q", msg, s)
|
||||
}
|
||||
|
||||
return int64(v), nil
|
||||
}
|
||||
|
||||
// rewriteDuration converts parsed duration segments, replacing extended
|
||||
// units (d, w, y) with their equivalent in hours.
|
||||
func rewriteDuration(dr durationparser.Result) (string, error) {
|
||||
var b strings.Builder
|
||||
if dr.Sign != "" {
|
||||
b.WriteString(dr.Sign)
|
||||
}
|
||||
for _, seg := range dr.Segments {
|
||||
s, err := rewriteSegment(seg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteString(s)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// rewriteSegment rewrites a single segment like {Digits:"1", Unit:"d"} to "24h".
|
||||
// Segments with standard units are returned unchanged.
|
||||
func rewriteSegment(seg durationparser.Segment) (string, error) {
|
||||
coeff, ok := durationCoefficients[seg.Unit]
|
||||
if !ok {
|
||||
return seg.Digits + seg.Unit, nil
|
||||
}
|
||||
|
||||
val, err := strconv.ParseFloat(seg.Digits, 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("time: invalid duration: bad value %q for unit %q", seg.Digits, seg.Unit)
|
||||
}
|
||||
hours := val * float64(coeff)
|
||||
return strconv.FormatFloat(hours, 'f', -1, 64) + "h", nil
|
||||
}
|
||||
|
||||
func toSafeUnixNano(t time.Time, iter func(*ast.Term) error) error {
|
||||
if t.Before(minDateAllowedForNsConversion) || t.After(maxDateAllowedForNsConversion) {
|
||||
return errors.New("time outside of valid range")
|
||||
@@ -77,16 +158,19 @@ func builtinTimeParseRFC3339Nanos(_ BuiltinContext, operands []*ast.Term, iter f
|
||||
|
||||
return toSafeUnixNano(result, iter)
|
||||
}
|
||||
|
||||
func builtinParseDurationNanos(_ BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error {
|
||||
duration, err := builtins.StringOperand(operands[0].Value, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := time.ParseDuration(string(duration))
|
||||
|
||||
ns, err := parseExtendedDuration(string(duration))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return iter(ast.NumberTerm(int64ToJSONNumber(int64(value))))
|
||||
|
||||
return iter(ast.NumberTerm(int64ToJSONNumber(ns)))
|
||||
}
|
||||
|
||||
// Represent exposed constants for formatting from the stdlib time pkg
|
||||
|
||||
@@ -43,3 +43,176 @@ func TestTimeSeeding(t *testing.T) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestParseDurationNanos_BadInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expErr string
|
||||
}{
|
||||
{
|
||||
name: "no known suffix",
|
||||
input: `badinput`,
|
||||
expErr: "time: invalid duration \"badinput\"",
|
||||
},
|
||||
{
|
||||
name: "bad digits with d suffix",
|
||||
input: `badinputd`,
|
||||
expErr: "time: invalid duration \"badinputd\"",
|
||||
},
|
||||
{
|
||||
name: "bad digits with w suffix",
|
||||
input: `abcw`,
|
||||
expErr: "time: invalid duration \"abcw\"",
|
||||
},
|
||||
{
|
||||
name: "bad digits with y suffix",
|
||||
input: `xyz.y`,
|
||||
expErr: "time: invalid duration \"xyz.y\"",
|
||||
},
|
||||
{
|
||||
name: "overflow days",
|
||||
input: `99999999999d`,
|
||||
expErr: `time: invalid duration "99999999999d"`,
|
||||
},
|
||||
{
|
||||
name: "overflow weeks",
|
||||
input: `99999999999w`,
|
||||
expErr: `time: invalid duration "99999999999w"`,
|
||||
},
|
||||
{
|
||||
name: "overflow years",
|
||||
input: `99999999999y`,
|
||||
expErr: `time: invalid duration "99999999999y"`,
|
||||
},
|
||||
{
|
||||
name: "invalid multi-unit",
|
||||
input: `1d2x`,
|
||||
expErr: `time: unknown unit "x" in duration "1d2x"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := builtinParseDurationNanos(BuiltinContext{}, []*ast.Term{
|
||||
ast.StringTerm(tc.input),
|
||||
}, func(a *ast.Term) error {
|
||||
return nil
|
||||
})
|
||||
if err.Error() != tc.expErr {
|
||||
t.Fatalf("expected error %q but got %q", tc.expErr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationNanos_ExtendedUnits(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expNs int64
|
||||
}{
|
||||
{
|
||||
name: "fractional days",
|
||||
input: "1.5d",
|
||||
expNs: int64(36 * time.Hour),
|
||||
},
|
||||
{
|
||||
name: "fractional weeks",
|
||||
input: "0.5w",
|
||||
expNs: int64(84 * time.Hour),
|
||||
},
|
||||
{
|
||||
name: "negative days",
|
||||
input: "-1d",
|
||||
expNs: int64(-24 * time.Hour),
|
||||
},
|
||||
{
|
||||
name: "zero days",
|
||||
input: "0d",
|
||||
expNs: 0,
|
||||
},
|
||||
{
|
||||
name: "zero weeks",
|
||||
input: "0w",
|
||||
expNs: 0,
|
||||
},
|
||||
{
|
||||
name: "zero years",
|
||||
input: "0y",
|
||||
expNs: 0,
|
||||
},
|
||||
{
|
||||
name: "days and hours",
|
||||
input: "1d2h",
|
||||
expNs: int64(26 * time.Hour),
|
||||
},
|
||||
{
|
||||
name: "hours and days",
|
||||
input: "2h1d",
|
||||
expNs: int64(26 * time.Hour),
|
||||
},
|
||||
{
|
||||
name: "days hours minutes",
|
||||
input: "1d2h30m",
|
||||
expNs: int64(26*time.Hour + 30*time.Minute),
|
||||
},
|
||||
{
|
||||
name: "weeks and days",
|
||||
input: "2w3d",
|
||||
expNs: int64((2*7*24 + 3*24) * time.Hour),
|
||||
},
|
||||
{
|
||||
name: "days and seconds",
|
||||
input: "1d30s",
|
||||
expNs: int64(24*time.Hour + 30*time.Second),
|
||||
},
|
||||
{
|
||||
name: "negative multi-unit",
|
||||
input: "-1d2h",
|
||||
expNs: int64(-26 * time.Hour),
|
||||
},
|
||||
{
|
||||
name: "days and milliseconds",
|
||||
input: "1d100ms",
|
||||
expNs: int64(24*time.Hour + 100*time.Millisecond),
|
||||
},
|
||||
{
|
||||
name: "days and nanoseconds",
|
||||
input: "1d500ns",
|
||||
expNs: int64(24*time.Hour + 500),
|
||||
},
|
||||
{
|
||||
name: "days and microseconds",
|
||||
input: "1d200us",
|
||||
expNs: int64(24*time.Hour + 200*time.Microsecond),
|
||||
},
|
||||
{
|
||||
name: "days and microseconds µs",
|
||||
input: "1d200µs",
|
||||
expNs: int64(24*time.Hour + 200*time.Microsecond),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var got int64
|
||||
err := builtinParseDurationNanos(BuiltinContext{}, []*ast.Term{
|
||||
ast.StringTerm(tc.input),
|
||||
}, func(a *ast.Term) error {
|
||||
v, ok := a.Value.(ast.Number).Int64()
|
||||
if !ok {
|
||||
t.Fatal("expected int64 result")
|
||||
}
|
||||
got = v
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.expNs {
|
||||
t.Fatalf("expected %d but got %d", tc.expNs, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user