diff --git a/funcs/cel_exports.go b/funcs/cel_exports.go index 4ee9b4780..a6b5dd0a0 100644 --- a/funcs/cel_exports.go +++ b/funcs/cel_exports.go @@ -191,6 +191,7 @@ var CelEnvOption = []cel.EnvOption{ timeParseDurationGen, timeSinceGen, timeUntilGen, + timeInTimeRangeGen, uuidV1Gen, uuidV4Gen, diff --git a/funcs/time.go b/funcs/time.go index 4c13e9ecd..1d97469bd 100644 --- a/funcs/time.go +++ b/funcs/time.go @@ -190,6 +190,66 @@ func (TimeFuncs) Until(n gotime.Time) gotime.Duration { return gotime.Until(n) } +// InTimeRange reports whether the time of day of t falls within [start, end] (both inclusive). +// start and end are "HH:MM" or "HH:MM:SS" strings. +// +// Examples: +// +// InTimeRange(t, "09:00", "17:00") → true for 9:00:00–17:00:59 +// InTimeRange(t, "09:30", "17:30") → true for 9:30:00–17:30:59 +// InTimeRange(t, "09:00:00", "17:00:30") → true for 9:00:00–17:00:30 +func (TimeFuncs) InTimeRange(t any, start, end string) (bool, error) { + ts, err := toTime(t) + if err != nil { + return false, err + } + startSecs, err := parseTimeOfDay(start) + if err != nil { + return false, err + } + endSecs, err := parseTimeOfDay(end) + if err != nil { + return false, err + } + tSecs := ts.Hour()*3600 + ts.Minute()*60 + ts.Second() + return tSecs >= startSecs && tSecs <= endSecs, nil +} + +// parseTimeOfDay parses a "HH:MM" or "HH:MM:SS" string and returns +// the total number of seconds since midnight. +func parseTimeOfDay(s string) (int, error) { + for _, layout := range []string{"15:04:05", "15:04"} { + if t, err := gotime.Parse(layout, s); err == nil { + return t.Hour()*3600 + t.Minute()*60 + t.Second(), nil + } + } + return 0, fmt.Errorf("cannot parse %q as a time of day (expected HH:MM or HH:MM:SS)", s) +} + +// toTime converts a timestamp value to a time.Time. +// Supported input types: time.Time, or a string in RFC3339Nano / RFC3339 / +// "2006-01-02T15:04:05" / "2006-01-02 15:04:05" format. +func toTime(v any) (gotime.Time, error) { + switch t := v.(type) { + case gotime.Time: + return t, nil + case string: + layouts := []string{ + gotime.RFC3339Nano, + gotime.RFC3339, + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + } + for _, layout := range layouts { + if ts, err := gotime.Parse(layout, t); err == nil { + return ts, nil + } + } + return gotime.Time{}, fmt.Errorf("cannot parse %q as a timestamp", t) + } + return gotime.Time{}, fmt.Errorf("cannot convert %T to a timestamp", v) +} + // InBusinessHour returns nil when no business hours are configured. func (TimeFuncs) InBusinessHour(value string) (any, error) { in, err := inBusinessHour(value) diff --git a/funcs/time_cel.go b/funcs/time_cel.go new file mode 100644 index 000000000..832fbfbb6 --- /dev/null +++ b/funcs/time_cel.go @@ -0,0 +1,33 @@ +package funcs + +import ( + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" +) + +// timeInTimeRangeGen exposes time.InTimeRange(t, start, end) → bool. +// Returns true when the time of day of t falls within [start, end] inclusive. +// start and end are "HH:MM" or "HH:MM:SS" strings. +// Accepts a timestamp (time.Time) or an RFC3339 string as the first argument. +// +// Examples: +// +// time.InTimeRange(t, "09:00", "17:00") +// time.InTimeRange(t, "09:30:00", "17:30:00") +var timeInTimeRangeGen = cel.Function("time.InTimeRange", + cel.Overload("time.InTimeRange_any_string_string", + []*cel.Type{cel.AnyType, cel.StringType, cel.StringType}, + cel.BoolType, + cel.FunctionBinding(func(args ...ref.Val) ref.Val { + var x TimeFuncs + start := args[1].Value().(string) + end := args[2].Value().(string) + result, err := x.InTimeRange(args[0].Value(), start, end) + if err != nil { + return types.WrapErr(err) + } + return types.Bool(result) + }), + ), +) diff --git a/tests/cel_test.go b/tests/cel_test.go index 4b6f21bb4..200ed1dea 100644 --- a/tests/cel_test.go +++ b/tests/cel_test.go @@ -550,6 +550,54 @@ func TestCelDates(t *testing.T) { runTests(t, tests) } +func TestCelTimestampGetHoursMinutes(t *testing.T) { + ts, _ := time.Parse(time.RFC3339, "2024-06-15T14:30:45Z") + + runTests(t, []Test{ + // getHours and getMinutes on a time.Time variable + {map[string]any{"t": ts}, `t.getHours()`, "14"}, + {map[string]any{"t": ts}, `t.getMinutes()`, "30"}, + + // combined — check it's within 9–17 and past the half hour + {map[string]any{"t": ts}, `t.getHours() >= 9 && t.getHours() <= 17`, "true"}, + {map[string]any{"t": ts}, `t.getHours() == 14 && t.getMinutes() == 30`, "true"}, + + // with a timezone offset (CEL built-in tz support) + {map[string]any{"t": ts}, `t.getHours("America/New_York")`, "10"}, // UTC-4 in June + {map[string]any{"t": ts}, `t.getMinutes("America/New_York")`, "30"}, + }) +} + +func TestCelTimeInTimeRange(t *testing.T) { + // various timestamps to test boundary conditions + ts14_30_45, _ := time.Parse(time.RFC3339, "2024-06-15T14:30:45Z") // 14:30:45 — inside 9–17 + ts09_00_00, _ := time.Parse(time.RFC3339, "2024-06-15T09:00:00Z") // 09:00:00 — start boundary + ts17_00_00, _ := time.Parse(time.RFC3339, "2024-06-15T17:00:00Z") // 17:00:00 — end boundary + ts17_00_01, _ := time.Parse(time.RFC3339, "2024-06-15T17:00:01Z") // 17:00:01 — one second past end + ts08_59_59, _ := time.Parse(time.RFC3339, "2024-06-15T08:59:59Z") // 08:59:59 — one second before start + ts09_30_00, _ := time.Parse(time.RFC3339, "2024-06-15T09:30:00Z") // 09:30:00 — inside with HH:MM:SS range + + runTests(t, []Test{ + // time.Time input, HH:MM boundaries + {map[string]any{"t": ts14_30_45}, `time.InTimeRange(t, "09:00", "17:00")`, "true"}, + {map[string]any{"t": ts09_00_00}, `time.InTimeRange(t, "09:00", "17:00")`, "true"}, // start boundary + {map[string]any{"t": ts17_00_00}, `time.InTimeRange(t, "09:00", "17:00")`, "true"}, // end boundary + {map[string]any{"t": ts17_00_01}, `time.InTimeRange(t, "09:00", "17:00")`, "false"}, // one second past end + {map[string]any{"t": ts08_59_59}, `time.InTimeRange(t, "09:00", "17:00")`, "false"}, // one second before start + + // RFC3339 string input, HH:MM boundaries + {nil, `time.InTimeRange("2024-06-15T14:30:00Z", "09:00", "17:00")`, "true"}, + {nil, `time.InTimeRange("2024-06-15T08:59:59Z", "09:00", "17:00")`, "false"}, + {nil, `time.InTimeRange("2024-06-15T17:00:01Z", "09:00", "17:00")`, "false"}, + + // HH:MM:SS boundaries (seconds precision) + {map[string]any{"t": ts09_30_00}, `time.InTimeRange(t, "09:30:00", "17:30:00")`, "true"}, + {nil, `time.InTimeRange("2024-06-15T09:29:59Z", "09:30:00", "17:30:00")`, "false"}, // one second before HH:MM:SS start + {nil, `time.InTimeRange("2024-06-15T17:30:00Z", "09:30:00", "17:30:00")`, "true"}, // exact HH:MM:SS end boundary + {nil, `time.InTimeRange("2024-06-15T17:30:01Z", "09:30:00", "17:30:00")`, "false"}, // one second past HH:MM:SS end + }) +} + func TestCelVariadic(t *testing.T) { testData := []struct { Input string