Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 10 additions & 0 deletions expr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions patcher/with_now.go
Original file line number Diff line number Diff line change
@@ -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})
}
44 changes: 44 additions & 0 deletions patcher/with_now_test.go
Original file line number Diff line number Diff line change
@@ -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))
}