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
5 changes: 5 additions & 0 deletions plugin/filter/ir.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ type ConstantCondition struct {

func (*ConstantCondition) isCondition() {}

// ThisDayCondition matches memos created on today's month-day in any year.
type ThisDayCondition struct{}

func (*ThisDayCondition) isCondition() {}

// ValueExpr models arithmetic or scalar expressions whose result feeds a comparison.
type ValueExpr interface {
isValueExpr()
Expand Down
2 changes: 2 additions & 0 deletions plugin/filter/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ func buildCallCondition(call *exprv1.Expr_Call, schema Schema) (Condition, error
return buildInCondition(call, schema)
case "contains":
return buildContainsCondition(call, schema)
case "this_day":
return &ThisDayCondition{}, nil
default:
val, ok, err := evaluateBool(call)
if err != nil {
Expand Down
32 changes: 32 additions & 0 deletions plugin/filter/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ func (r *renderer) renderCondition(cond Condition) (renderResult, error) {
return renderResult{trivial: true}, nil
}
return renderResult{sql: "1 = 0", unsatisfiable: true}, nil
case *ThisDayCondition:
return r.renderThisDayCondition()
default:
return renderResult{}, errors.Errorf("unsupported condition type %T", c)
}
Expand Down Expand Up @@ -563,6 +565,36 @@ func (r *renderer) wrapWithNullCheck(arrayExpr, condition string) string {
return fmt.Sprintf("(%s AND %s)", condition, nullCheck)
}

func (r *renderer) renderThisDayCondition() (renderResult, error) {
field, ok := r.schema.Field("created_ts")
if !ok {
return renderResult{}, errors.New("created_ts field not found in schema")
}
column := qualifyColumn(r.dialect, field.Column)

var sql string
switch r.dialect {
case DialectSQLite:
sql = fmt.Sprintf(
"strftime('%%m-%%d', datetime(%s, 'unixepoch')) = strftime('%%m-%%d', 'now')",
column,
)
case DialectMySQL:
sql = fmt.Sprintf(
"DATE_FORMAT(FROM_UNIXTIME(%s), '%%m-%%d') = DATE_FORMAT(NOW(), '%%m-%%d')",
column,
)
case DialectPostgres:
sql = fmt.Sprintf(
"TO_CHAR(TO_TIMESTAMP(%s), 'MM-DD') = TO_CHAR(NOW(), 'MM-DD')",
column,
)
default:
return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect)
}
return renderResult{sql: sql}, nil
}

func (r *renderer) jsonBoolPredicate(field Field) (string, error) {
expr := jsonExtractExpr(r.dialect, field)
switch r.dialect {
Expand Down
9 changes: 9 additions & 0 deletions plugin/filter/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ var nowFunction = cel.Function("now",
),
)

var thisDayFunction = cel.Function("this_day",
cel.Overload("this_day", []*cel.Type{}, cel.BoolType,
cel.FunctionBinding(func(_ ...ref.Val) ref.Val {
return types.Bool(true)
}),
),
)

// NewSchema constructs the memo filter schema and CEL environment.
func NewSchema() Schema {
fields := map[string]Field{
Expand Down Expand Up @@ -240,6 +248,7 @@ func NewSchema() Schema {
cel.Variable("has_code", cel.BoolType),
cel.Variable("has_incomplete_tasks", cel.BoolType),
nowFunction,
thisDayFunction,
}

return Schema{
Expand Down
5 changes: 5 additions & 0 deletions store/test/filter_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ func (b *MemoBuilder) Property(fn func(*storepb.MemoPayload_Property)) *MemoBuil
return b
}

func (b *MemoBuilder) CreatedTs(ts int64) *MemoBuilder {
b.memo.CreatedTs = ts
return b
}

func (b *MemoBuilder) Build() *store.Memo {
return b.memo
}
Expand Down
103 changes: 103 additions & 0 deletions store/test/memo_filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,109 @@ func TestMemoFilterAllComparisonOperators(t *testing.T) {
require.Len(t, memos, 1)
}

// =============================================================================
// ThisDay Function Tests
// Schema: this_day() - matches memos created on today's month-day in any year
// =============================================================================

func TestMemoFilterThisDay(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()

now := time.Now()

// Memo created today (default timestamp) - should match
tc.CreateMemo(NewMemoBuilder("memo-today", tc.User.ID).Content("Created today"))

// Memo created on the same month-day last year - should match
lastYear := time.Date(now.Year()-1, now.Month(), now.Day(), 12, 0, 0, 0, time.UTC)
tc.CreateMemo(NewMemoBuilder("memo-last-year", tc.User.ID).
Content("Same day last year").
CreatedTs(lastYear.Unix()))

// Memo created on a different day - should NOT match
differentDay := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC).AddDate(0, 0, -15)
tc.CreateMemo(NewMemoBuilder("memo-different-day", tc.User.ID).
Content("Different day").
CreatedTs(differentDay.Unix()))

// Test: this_day() should match memos with today's month-day
memos := tc.ListWithFilter(`this_day()`)
require.Len(t, memos, 2, "Should match memos created on today's month-day in any year")
}

func TestMemoFilterThisDayCombinedWithOtherFilters(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()

now := time.Now()
lastYear := time.Date(now.Year()-1, now.Month(), now.Day(), 12, 0, 0, 0, time.UTC)

// Memo from today with tag
tc.CreateMemo(NewMemoBuilder("memo-today-tagged", tc.User.ID).
Content("Today tagged").
Tags("journal").
CreatedTs(now.Unix()))

// Memo from same day last year without tag
tc.CreateMemo(NewMemoBuilder("memo-lastyear-untagged", tc.User.ID).
Content("Last year untagged").
CreatedTs(lastYear.Unix()))

// Memo from different day with tag
differentDay := now.AddDate(0, 0, -15)
tc.CreateMemo(NewMemoBuilder("memo-diffday-tagged", tc.User.ID).
Content("Different day tagged").
Tags("journal").
CreatedTs(differentDay.Unix()))

// Test: this_day() && tag in ["journal"]
memos := tc.ListWithFilter(`this_day() && tag in ["journal"]`)
require.Len(t, memos, 1, "Should match only today's tagged memo")
require.Contains(t, memos[0].Payload.Tags, "journal")
}

func TestMemoFilterThisDayNoMatches(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()

now := time.Now()

// Only create memos on a different day
differentDay := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC).AddDate(0, 0, -15)
tc.CreateMemo(NewMemoBuilder("memo-other-day", tc.User.ID).
Content("Other day").
CreatedTs(differentDay.Unix()))

memos := tc.ListWithFilter(`this_day()`)
require.Len(t, memos, 0, "Should not match memos created on a different day")
}

func TestMemoFilterThisDayNegated(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()

now := time.Now()

// Memo created today
tc.CreateMemo(NewMemoBuilder("memo-today", tc.User.ID).Content("Created today"))

// Memo created on a different day
differentDay := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC).AddDate(0, 0, -15)
tc.CreateMemo(NewMemoBuilder("memo-different-day", tc.User.ID).
Content("Different day").
CreatedTs(differentDay.Unix()))

// Test: !this_day() should match memos NOT created on today's month-day
memos := tc.ListWithFilter(`!this_day()`)
require.Len(t, memos, 1, "Should match only the memo from a different day")
require.Equal(t, "Different day", memos[0].Content)
}

// =============================================================================
// Logical Operator Tests
// Operators: && (AND), || (OR), ! (NOT)
Expand Down