diff --git a/vm/datemath.go b/vm/datemath.go index 909a15a..734c92d 100644 --- a/vm/datemath.go +++ b/vm/datemath.go @@ -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) { +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) @@ -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) { @@ -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)...) } @@ -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))) + } +} diff --git a/vm/datemath_test.go b/vm/datemath_test.go index 5599c8d..e75277c 100644 --- a/vm/datemath_test.go +++ b/vm/datemath_test.go @@ -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()) + }) + } +}