Skip to content

Commit

Permalink
Provide an option to ensure time operations are in UTC by default (#560)
Browse files Browse the repository at this point in the history
* Provide an option to ensure time operations are in UTC by default
* Make it possible to opt-in or opt-out using a feature flag
* Minor refinement to library.go setup for stdLib compile options
* Fixed issue with substring that caused wrong time offsets in minutes
  • Loading branch information
TristonianJones committed Jul 6, 2022
1 parent 2a36bec commit c04cadc
Show file tree
Hide file tree
Showing 9 changed files with 466 additions and 22 deletions.
1 change: 1 addition & 0 deletions cel/BUILD.bazel
Expand Up @@ -23,6 +23,7 @@ go_library(
"//checker/decls:go_default_library",
"//common:go_default_library",
"//common/containers:go_default_library",
"//common/overloads:go_default_library",
"//common/types:go_default_library",
"//common/types/pb:go_default_library",
"//common/types/ref:go_default_library",
Expand Down
87 changes: 85 additions & 2 deletions cel/cel_test.go
Expand Up @@ -144,7 +144,7 @@ func TestAbbrevsParsed(t *testing.T) {
}
}

func TestAbbrevs_Disambiguation(t *testing.T) {
func TestAbbrevsDisambiguation(t *testing.T) {
env, err := NewEnv(
Abbrevs("external.Expr"),
Container("google.api.expr.v1alpha1"),
Expand Down Expand Up @@ -1085,7 +1085,7 @@ func TestResidualAstComplex(t *testing.T) {
}
}

func Benchmark_EvalOptions(b *testing.B) {
func BenchmarkEvalOptions(b *testing.B) {
e, _ := NewEnv(
Variable("ai", IntType),
Variable("ar", MapType(StringType, StringType)),
Expand Down Expand Up @@ -1650,6 +1650,89 @@ func TestRegexOptimizer(t *testing.T) {
}
}

func TestDefaultUTCTimeZone(t *testing.T) {
env, err := NewEnv(Variable("x", TimestampType), DefaultUTCTimeZone(true))
if err != nil {
t.Fatalf("NewEnv() failed: %v", err)
}
ast, iss := env.Compile(`
x.getFullYear() == 1970
&& x.getMonth() == 0
&& x.getDayOfYear() == 0
&& x.getDayOfMonth() == 0
&& x.getDate() == 1
&& x.getDayOfWeek() == 4
&& x.getHours() == 2
&& x.getMinutes() == 5
&& x.getSeconds() == 6
&& x.getMilliseconds() == 1
&& x.getFullYear('-07:30') == 1969
&& x.getDayOfYear('-07:30') == 364
&& x.getMonth('-07:30') == 11
&& x.getDayOfMonth('-07:30') == 30
&& x.getDate('-07:30') == 31
&& x.getDayOfWeek('-07:30') == 3
&& x.getHours('-07:30') == 18
&& x.getMinutes('-07:30') == 35
&& x.getSeconds('-07:30') == 6
&& x.getMilliseconds('-07:30') == 1
&& x.getFullYear('23:15') == 1970
&& x.getDayOfYear('23:15') == 1
&& x.getMonth('23:15') == 0
&& x.getDayOfMonth('23:15') == 1
&& x.getDate('23:15') == 2
&& x.getDayOfWeek('23:15') == 5
&& x.getHours('23:15') == 1
&& x.getMinutes('23:15') == 20
&& x.getSeconds('23:15') == 6
&& x.getMilliseconds('23:15') == 1
`)
if iss.Err() != nil {
t.Fatalf("env.Compile() failed: %v", iss.Err())
}
prg, err := env.Program(ast)
if err != nil {
t.Fatalf("env.Program() failed: %v", err)
}
out, _, err := prg.Eval(map[string]interface{}{"x": time.Unix(7506, 1000000).Local()})
if err != nil {
t.Fatalf("prg.Eval() failed: %v", err)
}
if out != types.True {
t.Errorf("Eval() got %v, wanted true", out)
}
}

func TestDefaultUTCTimeZoneError(t *testing.T) {
env, err := NewEnv(Variable("x", TimestampType), DefaultUTCTimeZone(true))
if err != nil {
t.Fatalf("NewEnv() failed: %v", err)
}
ast, iss := env.Compile(`
x.getFullYear(':xx') == 1969
|| x.getDayOfYear('xx:') == 364
|| x.getMonth('Am/Ph') == 11
|| x.getDayOfMonth('Am/Ph') == 30
|| x.getDate('Am/Ph') == 31
|| x.getDayOfWeek('Am/Ph') == 3
|| x.getHours('Am/Ph') == 19
|| x.getMinutes('Am/Ph') == 5
|| x.getSeconds('Am/Ph') == 6
|| x.getMilliseconds('Am/Ph') == 1
`)
if iss.Err() != nil {
t.Fatalf("env.Compile() failed: %v", iss.Err())
}
prg, err := env.Program(ast)
if err != nil {
t.Fatalf("env.Program() failed: %v", err)
}
out, _, err := prg.Eval(map[string]interface{}{"x": time.Unix(7506, 1000000).Local()})
if err == nil {
t.Fatalf("prg.Eval() got %v wanted error", out)
}
}

func interpret(t *testing.T, env *Env, expr string, vars interface{}) (ref.Val, error) {
t.Helper()
ast, iss := env.Compile(expr)
Expand Down
8 changes: 8 additions & 0 deletions cel/env.go
Expand Up @@ -452,6 +452,14 @@ func (e *Env) configure(opts []EnvOption) (*Env, error) {
}
}

// If the default UTC timezone fix has been enabled, make sure the library is configured
if e.HasFeature(featureDefaultUTCTimeZone) {
e, err = Lib(timeUTCLibrary{})(e)
if err != nil {
return nil, err
}
}

// Initialize all of the functions configured within the environment.
for _, fn := range e.functions {
err = fn.init()
Expand Down
243 changes: 243 additions & 0 deletions cel/library.go
Expand Up @@ -15,7 +15,14 @@
package cel

import (
"strconv"
"strings"
"time"

"github.com/google/cel-go/checker"
"github.com/google/cel-go/common/overloads"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/interpreter/functions"
)

Expand Down Expand Up @@ -74,3 +81,239 @@ func (stdLibrary) ProgramOptions() []ProgramOption {
Functions(functions.StandardOverloads()...),
}
}

type timeUTCLibrary struct{}

func (timeUTCLibrary) CompileOptions() []EnvOption {
return timestampOverloadDeclarations
}

func (timeUTCLibrary) ProgramOptions() []ProgramOption {
return []ProgramOption{}
}

// Declarations and functions which enable using UTC on time.Time inputs when the timezone is unspecified
// in the CEL expression.
var (
utcTZ = types.String("UTC")

timestampOverloadDeclarations = []EnvOption{
Function(overloads.TimeGetFullYear,
MemberOverload(overloads.TimestampToYear, []*Type{TimestampType}, IntType,
UnaryBinding(func(ts ref.Val) ref.Val {
return timestampGetFullYear(ts, utcTZ)
}),
),
MemberOverload(overloads.TimestampToYearWithTz, []*Type{TimestampType, StringType}, IntType,
BinaryBinding(timestampGetFullYear),
),
),
Function(overloads.TimeGetMonth,
MemberOverload(overloads.TimestampToMonth, []*Type{TimestampType}, IntType,
UnaryBinding(func(ts ref.Val) ref.Val {
return timestampGetMonth(ts, utcTZ)
}),
),
MemberOverload(overloads.TimestampToMonthWithTz, []*Type{TimestampType, StringType}, IntType,
BinaryBinding(timestampGetMonth),
),
),
Function(overloads.TimeGetDayOfYear,
MemberOverload(overloads.TimestampToDayOfYear, []*Type{TimestampType}, IntType,
UnaryBinding(func(ts ref.Val) ref.Val {
return timestampGetDayOfYear(ts, utcTZ)
}),
),
MemberOverload(overloads.TimestampToDayOfYearWithTz, []*Type{TimestampType, StringType}, IntType,
BinaryBinding(func(ts, tz ref.Val) ref.Val {
return timestampGetDayOfYear(ts, tz)
}),
),
),
Function(overloads.TimeGetDayOfMonth,
MemberOverload(overloads.TimestampToDayOfMonthZeroBased, []*Type{TimestampType}, IntType,
UnaryBinding(func(ts ref.Val) ref.Val {
return timestampGetDayOfMonthZeroBased(ts, utcTZ)
}),
),
MemberOverload(overloads.TimestampToDayOfMonthZeroBasedWithTz, []*Type{TimestampType, StringType}, IntType,
BinaryBinding(timestampGetDayOfMonthZeroBased),
),
),
Function(overloads.TimeGetDate,
MemberOverload(overloads.TimestampToDayOfMonthOneBased, []*Type{TimestampType}, IntType,
UnaryBinding(func(ts ref.Val) ref.Val {
return timestampGetDayOfMonthOneBased(ts, utcTZ)
}),
),
MemberOverload(overloads.TimestampToDayOfMonthOneBasedWithTz, []*Type{TimestampType, StringType}, IntType,
BinaryBinding(timestampGetDayOfMonthOneBased),
),
),
Function(overloads.TimeGetDayOfWeek,
MemberOverload(overloads.TimestampToDayOfWeek, []*Type{TimestampType}, IntType,
UnaryBinding(func(ts ref.Val) ref.Val {
return timestampGetDayOfWeek(ts, utcTZ)
}),
),
MemberOverload(overloads.TimestampToDayOfWeekWithTz, []*Type{TimestampType, StringType}, IntType,
BinaryBinding(timestampGetDayOfWeek),
),
),
Function(overloads.TimeGetHours,
MemberOverload(overloads.TimestampToHours, []*Type{TimestampType}, IntType,
UnaryBinding(func(ts ref.Val) ref.Val {
return timestampGetHours(ts, utcTZ)
}),
),
MemberOverload(overloads.TimestampToHoursWithTz, []*Type{TimestampType, StringType}, IntType,
BinaryBinding(timestampGetHours),
),
),
Function(overloads.TimeGetMinutes,
MemberOverload(overloads.TimestampToMinutes, []*Type{TimestampType}, IntType,
UnaryBinding(func(ts ref.Val) ref.Val {
return timestampGetMinutes(ts, utcTZ)
}),
),
MemberOverload(overloads.TimestampToMinutesWithTz, []*Type{TimestampType, StringType}, IntType,
BinaryBinding(timestampGetMinutes),
),
),
Function(overloads.TimeGetSeconds,
MemberOverload(overloads.TimestampToSeconds, []*Type{TimestampType}, IntType,
UnaryBinding(func(ts ref.Val) ref.Val {
return timestampGetSeconds(ts, utcTZ)
}),
),
MemberOverload(overloads.TimestampToSecondsWithTz, []*Type{TimestampType, StringType}, IntType,
BinaryBinding(timestampGetSeconds),
),
),
Function(overloads.TimeGetMilliseconds,
MemberOverload(overloads.TimestampToMilliseconds, []*Type{TimestampType}, IntType,
UnaryBinding(func(ts ref.Val) ref.Val {
return timestampGetMilliseconds(ts, utcTZ)
}),
),
MemberOverload(overloads.TimestampToMillisecondsWithTz, []*Type{TimestampType, StringType}, IntType,
BinaryBinding(timestampGetMilliseconds),
),
),
}
)

func timestampGetFullYear(ts, tz ref.Val) ref.Val {
t, err := inTimeZone(ts, tz)
if err != nil {
return types.NewErr(err.Error())
}
return types.Int(t.Year())
}

func timestampGetMonth(ts, tz ref.Val) ref.Val {
t, err := inTimeZone(ts, tz)
if err != nil {
return types.NewErr(err.Error())
}
// CEL spec indicates that the month should be 0-based, but the Time value
// for Month() is 1-based.
return types.Int(t.Month() - 1)
}

func timestampGetDayOfYear(ts, tz ref.Val) ref.Val {
t, err := inTimeZone(ts, tz)
if err != nil {
return types.NewErr(err.Error())
}
return types.Int(t.YearDay() - 1)
}

func timestampGetDayOfMonthZeroBased(ts, tz ref.Val) ref.Val {
t, err := inTimeZone(ts, tz)
if err != nil {
return types.NewErr(err.Error())
}
return types.Int(t.Day() - 1)
}

func timestampGetDayOfMonthOneBased(ts, tz ref.Val) ref.Val {
t, err := inTimeZone(ts, tz)
if err != nil {
return types.NewErr(err.Error())
}
return types.Int(t.Day())
}

func timestampGetDayOfWeek(ts, tz ref.Val) ref.Val {
t, err := inTimeZone(ts, tz)
if err != nil {
return types.NewErr(err.Error())
}
return types.Int(t.Weekday())
}

func timestampGetHours(ts, tz ref.Val) ref.Val {
t, err := inTimeZone(ts, tz)
if err != nil {
return types.NewErr(err.Error())
}
return types.Int(t.Hour())
}

func timestampGetMinutes(ts, tz ref.Val) ref.Val {
t, err := inTimeZone(ts, tz)
if err != nil {
return types.NewErr(err.Error())
}
return types.Int(t.Minute())
}

func timestampGetSeconds(ts, tz ref.Val) ref.Val {
t, err := inTimeZone(ts, tz)
if err != nil {
return types.NewErr(err.Error())
}
return types.Int(t.Second())
}

func timestampGetMilliseconds(ts, tz ref.Val) ref.Val {
t, err := inTimeZone(ts, tz)
if err != nil {
return types.NewErr(err.Error())
}
return types.Int(t.Nanosecond() / 1000000)
}

func inTimeZone(ts, tz ref.Val) (time.Time, error) {
t := ts.(types.Timestamp)
val := string(tz.(types.String))
ind := strings.Index(val, ":")
if ind == -1 {
loc, err := time.LoadLocation(val)
if err != nil {
return time.Time{}, err
}
return t.In(loc), nil
}

// If the input is not the name of a timezone (for example, 'US/Central'), it should be a numerical offset from UTC
// in the format ^(+|-)(0[0-9]|1[0-4]):[0-5][0-9]$. The numerical input is parsed in terms of hours and minutes.
hr, err := strconv.Atoi(string(val[0:ind]))
if err != nil {
return time.Time{}, err
}
min, err := strconv.Atoi(string(val[ind+1:]))
if err != nil {
return time.Time{}, err
}
var offset int
if string(val[0]) == "-" {
offset = hr*60 - min
} else {
offset = hr*60 + min
}
secondsEastOfUTC := int((time.Duration(offset) * time.Minute).Seconds())
timezone := time.FixedZone("", secondsEastOfUTC)
return t.In(timezone), nil
}

0 comments on commit c04cadc

Please sign in to comment.