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:
Sebastian Spaink
2026-04-13 15:20:51 -05:00
committed by GitHub
parent 04ee2c86ad
commit 1de861f2d6
10 changed files with 1981 additions and 5 deletions
+1 -1
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+34
View File
@@ -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
+13
View File
@@ -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
View File
@@ -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
+173
View File
@@ -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)
}
})
}
}