diff --git a/docs/configuration.md b/docs/configuration.md index 0aaaf99dd..ec7054cb3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -132,3 +132,17 @@ The timezone is used for the following functions: date("2024-11-23 12:00:00") // parses the date in the specified timezone now() // returns the current time in the specified timezone ``` + +## Mock now() + +Expressions that depend on `now()` are hard to test because the result changes +on every run. The [`MockNow`](https://pkg.go.dev/github.com/expr-lang/expr#MockNow) +option replaces `now()` with a fixed time, so the compiled program always +returns the same value. + +```go +program, err := expr.Compile(code, expr.MockNow(time.Date(2024, 11, 23, 12, 0, 0, 0, time.UTC))) +``` + +`MockNow` can be combined with [`Timezone`](#timezone) to return the fixed time +in the specified timezone. diff --git a/expr.go b/expr.go index 76fbd426f..1a553b450 100644 --- a/expr.go +++ b/expr.go @@ -216,6 +216,16 @@ func Timezone(name string) Option { }) } +// MockNow replaces now() calls with the given time. This is useful to get +// deterministic results when testing expressions that depend on now(). The +// replacement happens at compile time, so the compiled program always returns +// the given time from now(). +func MockNow(t time.Time) Option { + return Patch(patcher.WithNow{ + Now: t, + }) +} + // MaxNodes sets the maximum number of nodes allowed in the expression. // By default, the maximum number of nodes is conf.DefaultMaxNodes. // If MaxNodes is set to 0, the node budget check is disabled. diff --git a/patcher/with_now.go b/patcher/with_now.go new file mode 100644 index 000000000..2eed22f2d --- /dev/null +++ b/patcher/with_now.go @@ -0,0 +1,35 @@ +package patcher + +import ( + "time" + + "github.com/expr-lang/expr/ast" +) + +// WithNow replaces now() calls with a constant time. This is useful to get +// deterministic results when testing expressions that depend on the current +// time. The replacement happens while patching the AST, so the compiled +// program always returns the same time from now(). +type WithNow struct { + Now time.Time +} + +func (p WithNow) Visit(node *ast.Node) { + btin, ok := (*node).(*ast.BuiltinNode) + if !ok || btin.Name != "now" { + return + } + now := p.Now + // Preserve the location injected by WithTimezone, if any, so that + // Timezone and MockNow can be combined. + for _, arg := range btin.Arguments { + c, ok := arg.(*ast.ConstantNode) + if !ok { + continue + } + if loc, ok := c.Value.(*time.Location); ok { + now = now.In(loc) + } + } + ast.Patch(node, &ast.ConstantNode{Value: now}) +} diff --git a/patcher/with_now_test.go b/patcher/with_now_test.go new file mode 100644 index 000000000..400648486 --- /dev/null +++ b/patcher/with_now_test.go @@ -0,0 +1,44 @@ +package patcher_test + +import ( + "testing" + "time" + + "github.com/expr-lang/expr/internal/testify/require" + + "github.com/expr-lang/expr" +) + +func TestWithNow(t *testing.T) { + now := time.Date(2024, 5, 7, 23, 0, 0, 0, time.UTC) + + program, err := expr.Compile(`now()`, expr.MockNow(now)) + require.NoError(t, err) + + out, err := expr.Run(program, nil) + require.NoError(t, err) + require.Equal(t, now, out) +} + +func TestWithNow_is_deterministic(t *testing.T) { + now := time.Date(2024, 5, 7, 23, 0, 0, 0, time.UTC) + + program, err := expr.Compile(`now().Year()`, expr.MockNow(now)) + require.NoError(t, err) + + out, err := expr.Run(program, nil) + require.NoError(t, err) + require.Equal(t, 2024, out) +} + +func TestWithNow_combined_with_timezone(t *testing.T) { + now := time.Date(2024, 5, 7, 23, 0, 0, 0, time.UTC) + + program, err := expr.Compile(`now()`, expr.Timezone("Asia/Kamchatka"), expr.MockNow(now)) + require.NoError(t, err) + + out, err := expr.Run(program, nil) + require.NoError(t, err) + require.Equal(t, "Asia/Kamchatka", out.(time.Time).Location().String()) + require.True(t, out.(time.Time).Equal(now)) +}