Skip to content

Commit 7809f53

Browse files
authored
supporting datemath for between operator (#40)
* supporting datemath for between operator * added some comments
1 parent fe77212 commit 7809f53

File tree

2 files changed

+221
-3
lines changed

2 files changed

+221
-3
lines changed

vm/datemath.go

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,9 @@ func CalcBoundaryFns(n expr.Node) BoundaryFns {
4242
return findDateMathFn(n)
4343
}
4444

45-
// NewDateConverter takes a node expression
46-
func NewDateConverter(ctx expr.EvalIncludeContext, n expr.Node) (*DateConverter, error) {
45+
func NewDateConverterWithAnchorTime(ctx expr.EvalIncludeContext, n expr.Node, at time.Time) (*DateConverter, error) {
4746
dc := &DateConverter{
48-
at: time.Now(),
47+
at: at,
4948
}
5049
fns := findDateMathFn(n)
5150
dc.bt, dc.err = FindBoundary(dc.at, ctx, fns)
@@ -54,6 +53,11 @@ func NewDateConverter(ctx expr.EvalIncludeContext, n expr.Node) (*DateConverter,
5453
}
5554
return dc, dc.err
5655
}
56+
57+
// NewDateConverter takes a node expression
58+
func NewDateConverter(ctx expr.EvalIncludeContext, n expr.Node) (*DateConverter, error) {
59+
return NewDateConverterWithAnchorTime(ctx, n, time.Now())
60+
}
5761
func compareBoundaries(currBoundary, newBoundary time.Time) time.Time {
5862
// Should we check for is zero on the newBoundary?
5963
if currBoundary.IsZero() || newBoundary.Before(currBoundary) {
@@ -183,6 +187,16 @@ func findDateMathFn(node expr.Node) BoundaryFns {
183187
case *expr.UnaryNode:
184188
return findDateMathFn(n.Arg)
185189
case *expr.TriNode:
190+
// Only handle BETWEEN operator with specific node types
191+
if n.Operator.T == lex.TokenBetween && len(n.Args) == 3 {
192+
// Check if first arg is IdentityNode and other two are StringNodes
193+
fn := findBoundaryForBetween(n)
194+
if fn != nil {
195+
fns = append(fns, fn)
196+
return fns
197+
}
198+
}
199+
186200
for _, arg := range n.Args {
187201
fns = append(fns, findDateMathFn(arg)...)
188202
}
@@ -204,3 +218,97 @@ func findDateMathFn(node expr.Node) BoundaryFns {
204218
}
205219
return fns
206220
}
221+
222+
// findBoundaryForBetween calculates the next time boundary for a BETWEEN expression
223+
// with date math boundaries. It handles expressions like:
224+
//
225+
// time_column BETWEEN "now-3d" AND "now+3d"
226+
//
227+
// The function returns a boundary function that:
228+
// 1. Evaluates the comparison time (Ct) against the window boundaries
229+
// 2. Determines when the expression's boolean value will change
230+
// 3. Returns the appropriate re-evaluation time
231+
//
232+
// Example:
233+
//
234+
// Input: time_column BETWEEN "now-3d" AND "now+3d"
235+
// When: now = 2025-01-22
236+
// Window: 2025-01-19 to 2025-01-25
237+
//
238+
// If Ct = 2025-01-01 (left side of window):
239+
// - Expression is false
240+
// - Will always be false as window is moving forward
241+
// - Returns zero time (no re-evaluation needed)
242+
//
243+
// If Ct = 2025-01-30 (right side of window):
244+
// - Expression is false
245+
// - Will become true when window catches up (enter event)
246+
// - Returns re-evaluation time when this will enter the window
247+
//
248+
// If Ct = 2025-01-22 (inside window):
249+
// - Expression is true
250+
// - Will become false when Ct passes lower bound (exit event)
251+
// - Returns re-evaluation time when this will be exit the window
252+
func findBoundaryForBetween(n *expr.TriNode) func(d *DateConverter, ctx expr.EvalIncludeContext) {
253+
254+
// Check if first arg is IdentityNode and other two are StringNodes
255+
_, isFirstIdentity := n.Args[0].(*expr.IdentityNode)
256+
_, isSecondString := n.Args[1].(*expr.StringNode)
257+
_, isThirdString := n.Args[2].(*expr.StringNode)
258+
259+
if !isFirstIdentity || !isSecondString || !isThirdString {
260+
return nil
261+
}
262+
arg1, arg2, arg3 := n.Args[0], n.Args[1], n.Args[2]
263+
264+
// datemath only if both date args are relative to an anchor time like "now-1d"
265+
val2 := strings.ToLower(arg2.(*expr.StringNode).Text)
266+
val3 := strings.ToLower(arg3.(*expr.StringNode).Text)
267+
if !nowRegex.MatchString(val2) || !nowRegex.MatchString(val3) {
268+
return nil
269+
}
270+
271+
return func(d *DateConverter, ctx expr.EvalIncludeContext) {
272+
273+
lhv, ok := Eval(ctx, arg1)
274+
if !ok {
275+
return
276+
}
277+
ct, ok := value.ValueToTime(lhv)
278+
if !ok {
279+
d.err = fmt.Errorf("could not convert %T: %v to time.Time", lhv, lhv)
280+
return
281+
}
282+
283+
date1, err := datemath.EvalAnchor(d.at, val2)
284+
if err != nil {
285+
d.err = err
286+
return
287+
}
288+
289+
date2, err := datemath.EvalAnchor(d.at, val3)
290+
if err != nil {
291+
d.err = err
292+
return
293+
}
294+
295+
// assign lower and upper bounds
296+
lower, upper := date1, date2
297+
if date1.After(date2) {
298+
lower, upper = date2, date1
299+
}
300+
301+
if ct.Before(lower) {
302+
// out of window's lower bound, so will always be false
303+
return
304+
}
305+
306+
if ct.After(upper) || ct.Equal(upper) {
307+
// in the future or right in the border, so will enter the window later sometime in the future, do re-evaluate
308+
d.bt = compareBoundaries(d.bt, d.at.Add(ct.Sub(upper)))
309+
return
310+
}
311+
// currently in the window, so will exit the window in the future, do re-evaluate
312+
d.bt = compareBoundaries(d.bt, d.at.Add(ct.Sub(lower)))
313+
}
314+
}

vm/datemath_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,3 +253,113 @@ func TestDateMath(t *testing.T) {
253253
_, err = vm.NewDateConverter(evalCtx, fs.Filter)
254254
assert.Equal(t, nil, err)
255255
}
256+
257+
func TestDateBoundaryForBetween(t *testing.T) {
258+
today := time.Now()
259+
260+
type testCase struct {
261+
testName string
262+
filter string
263+
match bool
264+
expectedBoundaryTime time.Time
265+
lastEvtTs time.Time
266+
}
267+
268+
tests := []testCase{
269+
{
270+
testName: "within_window_1_day_before_lower_bound",
271+
filter: `FILTER last_event BETWEEN "now-2d" AND "now+3d"`,
272+
match: true,
273+
expectedBoundaryTime: today.AddDate(0, 0, 1), // Will exit after 1 day since lower bound is after 2 days
274+
lastEvtTs: today.AddDate(0, 0, -1),
275+
},
276+
{
277+
testName: "within_window_including_other_filters",
278+
filter: `FILTER AND( last_event BETWEEN "now-2d" AND "now+3d", exists subscription_expires )`,
279+
match: true,
280+
expectedBoundaryTime: today.AddDate(0, 0, 1), // Will exit after 1 day since lower bound is after 2 days
281+
lastEvtTs: today.AddDate(0, 0, -1),
282+
},
283+
{
284+
testName: "exact_current_time",
285+
filter: `FILTER last_event BETWEEN "now-2d" AND "now+3d"`,
286+
match: true,
287+
expectedBoundaryTime: today.AddDate(0, 0, 2), // Will exit after two days
288+
lastEvtTs: today,
289+
},
290+
{
291+
testName: "just_inside_lower",
292+
filter: `FILTER last_event BETWEEN "now-2d" AND "now+3d"`,
293+
match: true,
294+
expectedBoundaryTime: today.Add(time.Minute), // will be after a minute
295+
lastEvtTs: today.AddDate(0, 0, -2).Add(time.Minute), // 2 days ago + 1 minute
296+
},
297+
{
298+
testName: "just_inside_upper",
299+
filter: `FILTER last_event BETWEEN "now-2d" AND "now+3d"`,
300+
match: true,
301+
expectedBoundaryTime: today.AddDate(0, 0, 5).Add(-time.Minute), // as window is of 5 days and this one just entered
302+
lastEvtTs: today.AddDate(0, 0, 3).Add(-time.Minute), // will exit after 2 days later
303+
},
304+
{
305+
testName: "exact_boundary_lower",
306+
filter: `FILTER last_event BETWEEN "now-2d" AND "now+3d"`,
307+
match: false, // going to be false as it thinks it is out of window
308+
expectedBoundaryTime: today, // already in the lowerbound, so should exit now
309+
lastEvtTs: today.AddDate(0, 0, -2), // Exactly 2 days ago
310+
},
311+
{
312+
testName: "exact_boundary_upper",
313+
filter: `FILTER last_event BETWEEN "now-2d" AND "now+3d"`,
314+
match: false, // not entered yet
315+
expectedBoundaryTime: today, // should enter right now
316+
lastEvtTs: today.AddDate(0, 0, 3), // Exactly 3 days in future
317+
},
318+
{
319+
testName: "multiple_date_math",
320+
filter: `FILTER AND(last_event BETWEEN "now-2d" AND "now+3d", subscription_expires > "now+1d")`,
321+
match: true,
322+
expectedBoundaryTime: today.AddDate(0, 0, 2), // Will exit after 2 days last_event is in window and subscription_expires date is 6 days later
323+
lastEvtTs: today, // today
324+
},
325+
{
326+
testName: "not_condition",
327+
filter: `FILTER NOT(last_event BETWEEN "now-2d" AND "now+3d")`,
328+
match: true,
329+
expectedBoundaryTime: today.AddDate(0, 0, 1), // Will enter after a day as it will be inside of window
330+
lastEvtTs: today.AddDate(0, 0, 4), // 4 days in the future (right of window)
331+
},
332+
{
333+
testName: "multiple_between",
334+
filter: `FILTER AND( last_event BETWEEN "now-2d" AND "now+3d", subscription_expires BETWEEN "now+1d" AND "now+7d")`,
335+
match: true,
336+
expectedBoundaryTime: today.AddDate(0, 0, 1), // Will exit after 1 day due to last_event sliding out of window after 1 day
337+
lastEvtTs: today.AddDate(0, 0, -1), // 1 day ago
338+
},
339+
}
340+
341+
for _, tc := range tests {
342+
t.Run(tc.testName, func(t *testing.T) {
343+
evalCtx := datasource.NewContextMapTs(map[string]interface{}{
344+
"last_event": tc.lastEvtTs,
345+
"subscription_expires": today.Add(time.Hour * 24 * 6),
346+
"lastevent": map[string]time.Time{"signedup": today},
347+
"first.event": map[string]time.Time{"has.period": today},
348+
}, true, today)
349+
350+
includeCtx := &includectx{ContextReader: evalCtx}
351+
352+
fs := rel.MustParseFilter(tc.filter)
353+
354+
dc, err := vm.NewDateConverterWithAnchorTime(includeCtx, fs.Filter, today)
355+
require.NoError(t, err)
356+
require.True(t, dc.HasDateMath)
357+
358+
matched, evalOk := vm.Matches(includeCtx, fs)
359+
assert.True(t, evalOk)
360+
assert.Equal(t, tc.match, matched)
361+
362+
assert.Equal(t, tc.expectedBoundaryTime.Unix(), dc.Boundary().Unix())
363+
})
364+
}
365+
}

0 commit comments

Comments
 (0)