Skip to content
Merged
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
114 changes: 111 additions & 3 deletions vm/datemath.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,9 @@ func CalcBoundaryFns(n expr.Node) BoundaryFns {
return findDateMathFn(n)
}

// NewDateConverter takes a node expression
func NewDateConverter(ctx expr.EvalIncludeContext, n expr.Node) (*DateConverter, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add some tests that this behavior matches the timewindow function?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

func NewDateConverterWithAnchorTime(ctx expr.EvalIncludeContext, n expr.Node, at time.Time) (*DateConverter, error) {
dc := &DateConverter{
at: time.Now(),
at: at,
}
fns := findDateMathFn(n)
dc.bt, dc.err = FindBoundary(dc.at, ctx, fns)
Expand All @@ -54,6 +53,11 @@ func NewDateConverter(ctx expr.EvalIncludeContext, n expr.Node) (*DateConverter,
}
return dc, dc.err
}

// NewDateConverter takes a node expression
func NewDateConverter(ctx expr.EvalIncludeContext, n expr.Node) (*DateConverter, error) {
return NewDateConverterWithAnchorTime(ctx, n, time.Now())
}
func compareBoundaries(currBoundary, newBoundary time.Time) time.Time {
// Should we check for is zero on the newBoundary?
if currBoundary.IsZero() || newBoundary.Before(currBoundary) {
Expand Down Expand Up @@ -183,6 +187,16 @@ func findDateMathFn(node expr.Node) BoundaryFns {
case *expr.UnaryNode:
return findDateMathFn(n.Arg)
case *expr.TriNode:
// Only handle BETWEEN operator with specific node types
if n.Operator.T == lex.TokenBetween && len(n.Args) == 3 {
// Check if first arg is IdentityNode and other two are StringNodes
fn := findBoundaryForBetween(n)
if fn != nil {
fns = append(fns, fn)
return fns
}
}

for _, arg := range n.Args {
fns = append(fns, findDateMathFn(arg)...)
}
Expand All @@ -204,3 +218,97 @@ func findDateMathFn(node expr.Node) BoundaryFns {
}
return fns
}

// findBoundaryForBetween calculates the next time boundary for a BETWEEN expression
// with date math boundaries. It handles expressions like:
//
// time_column BETWEEN "now-3d" AND "now+3d"
//
// The function returns a boundary function that:
// 1. Evaluates the comparison time (Ct) against the window boundaries
// 2. Determines when the expression's boolean value will change
// 3. Returns the appropriate re-evaluation time
//
// Example:
//
// Input: time_column BETWEEN "now-3d" AND "now+3d"
// When: now = 2025-01-22
// Window: 2025-01-19 to 2025-01-25
//
// If Ct = 2025-01-01 (left side of window):
// - Expression is false
// - Will always be false as window is moving forward
// - Returns zero time (no re-evaluation needed)
//
// If Ct = 2025-01-30 (right side of window):
// - Expression is false
// - Will become true when window catches up (enter event)
// - Returns re-evaluation time when this will enter the window
//
// If Ct = 2025-01-22 (inside window):
// - Expression is true
// - Will become false when Ct passes lower bound (exit event)
// - Returns re-evaluation time when this will be exit the window
func findBoundaryForBetween(n *expr.TriNode) func(d *DateConverter, ctx expr.EvalIncludeContext) {

// Check if first arg is IdentityNode and other two are StringNodes
_, isFirstIdentity := n.Args[0].(*expr.IdentityNode)
_, isSecondString := n.Args[1].(*expr.StringNode)
_, isThirdString := n.Args[2].(*expr.StringNode)

if !isFirstIdentity || !isSecondString || !isThirdString {
return nil
}
arg1, arg2, arg3 := n.Args[0], n.Args[1], n.Args[2]

// datemath only if both date args are relative to an anchor time like "now-1d"
val2 := strings.ToLower(arg2.(*expr.StringNode).Text)
val3 := strings.ToLower(arg3.(*expr.StringNode).Text)
if !nowRegex.MatchString(val2) || !nowRegex.MatchString(val3) {
return nil
}

return func(d *DateConverter, ctx expr.EvalIncludeContext) {

lhv, ok := Eval(ctx, arg1)
if !ok {
return
}
ct, ok := value.ValueToTime(lhv)
if !ok {
d.err = fmt.Errorf("could not convert %T: %v to time.Time", lhv, lhv)
return
}

date1, err := datemath.EvalAnchor(d.at, val2)
if err != nil {
d.err = err
return
}

date2, err := datemath.EvalAnchor(d.at, val3)
if err != nil {
d.err = err
return
}

// assign lower and upper bounds
lower, upper := date1, date2
if date1.After(date2) {
lower, upper = date2, date1
}

if ct.Before(lower) {
// out of window's lower bound, so will always be false
return
}

if ct.After(upper) || ct.Equal(upper) {
// in the future or right in the border, so will enter the window later sometime in the future, do re-evaluate
d.bt = compareBoundaries(d.bt, d.at.Add(ct.Sub(upper)))
return
}
// currently in the window, so will exit the window in the future, do re-evaluate
d.bt = compareBoundaries(d.bt, d.at.Add(ct.Sub(lower)))
}
}
110 changes: 110 additions & 0 deletions vm/datemath_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,113 @@ func TestDateMath(t *testing.T) {
_, err = vm.NewDateConverter(evalCtx, fs.Filter)
assert.Equal(t, nil, err)
}

func TestDateBoundaryForBetween(t *testing.T) {
today := time.Now()

type testCase struct {
testName string
filter string
match bool
expectedBoundaryTime time.Time
lastEvtTs time.Time
}

tests := []testCase{
{
testName: "within_window_1_day_before_lower_bound",
filter: `FILTER last_event BETWEEN "now-2d" AND "now+3d"`,
match: true,
expectedBoundaryTime: today.AddDate(0, 0, 1), // Will exit after 1 day since lower bound is after 2 days
lastEvtTs: today.AddDate(0, 0, -1),
},
{
testName: "within_window_including_other_filters",
filter: `FILTER AND( last_event BETWEEN "now-2d" AND "now+3d", exists subscription_expires )`,
match: true,
expectedBoundaryTime: today.AddDate(0, 0, 1), // Will exit after 1 day since lower bound is after 2 days
lastEvtTs: today.AddDate(0, 0, -1),
},
{
testName: "exact_current_time",
filter: `FILTER last_event BETWEEN "now-2d" AND "now+3d"`,
match: true,
expectedBoundaryTime: today.AddDate(0, 0, 2), // Will exit after two days
lastEvtTs: today,
},
{
testName: "just_inside_lower",
filter: `FILTER last_event BETWEEN "now-2d" AND "now+3d"`,
match: true,
expectedBoundaryTime: today.Add(time.Minute), // will be after a minute
lastEvtTs: today.AddDate(0, 0, -2).Add(time.Minute), // 2 days ago + 1 minute
},
{
testName: "just_inside_upper",
filter: `FILTER last_event BETWEEN "now-2d" AND "now+3d"`,
match: true,
expectedBoundaryTime: today.AddDate(0, 0, 5).Add(-time.Minute), // as window is of 5 days and this one just entered
lastEvtTs: today.AddDate(0, 0, 3).Add(-time.Minute), // will exit after 2 days later
},
{
testName: "exact_boundary_lower",
filter: `FILTER last_event BETWEEN "now-2d" AND "now+3d"`,
match: false, // going to be false as it thinks it is out of window
expectedBoundaryTime: today, // already in the lowerbound, so should exit now
lastEvtTs: today.AddDate(0, 0, -2), // Exactly 2 days ago
},
{
testName: "exact_boundary_upper",
filter: `FILTER last_event BETWEEN "now-2d" AND "now+3d"`,
match: false, // not entered yet
expectedBoundaryTime: today, // should enter right now
lastEvtTs: today.AddDate(0, 0, 3), // Exactly 3 days in future
},
{
testName: "multiple_date_math",
filter: `FILTER AND(last_event BETWEEN "now-2d" AND "now+3d", subscription_expires > "now+1d")`,
match: true,
expectedBoundaryTime: today.AddDate(0, 0, 2), // Will exit after 2 days last_event is in window and subscription_expires date is 6 days later
lastEvtTs: today, // today
},
{
testName: "not_condition",
filter: `FILTER NOT(last_event BETWEEN "now-2d" AND "now+3d")`,
match: true,
expectedBoundaryTime: today.AddDate(0, 0, 1), // Will enter after a day as it will be inside of window
lastEvtTs: today.AddDate(0, 0, 4), // 4 days in the future (right of window)
},
{
testName: "multiple_between",
filter: `FILTER AND( last_event BETWEEN "now-2d" AND "now+3d", subscription_expires BETWEEN "now+1d" AND "now+7d")`,
match: true,
expectedBoundaryTime: today.AddDate(0, 0, 1), // Will exit after 1 day due to last_event sliding out of window after 1 day
lastEvtTs: today.AddDate(0, 0, -1), // 1 day ago
},
}

for _, tc := range tests {
t.Run(tc.testName, func(t *testing.T) {
evalCtx := datasource.NewContextMapTs(map[string]interface{}{
"last_event": tc.lastEvtTs,
"subscription_expires": today.Add(time.Hour * 24 * 6),
"lastevent": map[string]time.Time{"signedup": today},
"first.event": map[string]time.Time{"has.period": today},
}, true, today)

includeCtx := &includectx{ContextReader: evalCtx}

fs := rel.MustParseFilter(tc.filter)

dc, err := vm.NewDateConverterWithAnchorTime(includeCtx, fs.Filter, today)
require.NoError(t, err)
require.True(t, dc.HasDateMath)

matched, evalOk := vm.Matches(includeCtx, fs)
assert.True(t, evalOk)
assert.Equal(t, tc.match, matched)

assert.Equal(t, tc.expectedBoundaryTime.Unix(), dc.Boundary().Unix())
})
}
}