From 41ff22b0cc432543594992fc3ee390179e2b47b0 Mon Sep 17 00:00:00 2001 From: boojack Date: Tue, 7 Jul 2026 22:53:21 +0800 Subject: [PATCH] feat(filter): fold now.getXxx() accessors for dynamic date-part filters Timestamp accessors previously compiled only on schema fields, so a saved shortcut like created_ts.getMonth() == now.getMonth() && created_ts.getDate() == now.getDate() ("on this day") failed with unknown identifier "now" and the only workaround froze literal month/day values into the filter. Accessors on `now` now fold to literal date parts of the frozen per-compile clock (UTC, CEL bases: 0-based month/day-of-week), so such filters re-resolve on every query. Comparisons are also normalized for the renderer: a folded literal on the left swaps operands with the operator mirrored, and literal-vs-literal comparisons fold to a constant condition. --- internal/filter/README.md | 9 +- internal/filter/functions_test.go | 185 ++++++++++++++++++++++++++++++ internal/filter/parser.go | 144 ++++++++++++++++++++++- 3 files changed, 329 insertions(+), 9 deletions(-) diff --git a/internal/filter/README.md b/internal/filter/README.md index d32812ee2..0b85b78c6 100644 --- a/internal/filter/README.md +++ b/internal/filter/README.md @@ -78,9 +78,12 @@ stmt, _ := engine.CompileToStatement(ctx, `has_task_list && visibility == "PUBLI `getDayOfMonth()`, `getDayOfWeek()`, `getDayOfYear()`, `getHours()`, `getMinutes()`, `getSeconds()` render to date-part extraction (`strftime` / `EXTRACT` / `YEAR`/`MONTH`/…). Results are normalized to CEL's base (0-based - month, 0-based day-of-week with 0 = Sunday). Extraction is UTC on SQLite/Postgres - (epoch columns); on MySQL the `TIMESTAMP` column is read in the session time - zone. A timezone argument is not supported. + month, 0-based day-of-week with 0 = Sunday). The same accessors on `now` fold + to literal date parts of the frozen evaluation time (UTC), so saved filters + like `created_ts.getMonth() == now.getMonth() && created_ts.getDate() == + now.getDate()` ("on this day") re-resolve on every compile. Extraction is UTC + on SQLite/Postgres (epoch columns); on MySQL the `TIMESTAMP` column is read in + the session time zone. A timezone argument is not supported. - **Set Operations** — `ext.Sets()`: `sets.contains(tags, [...])`, `sets.intersects(tags, [...])`, and `sets.equivalent(tags, [...])` desugar to exact-membership checks (AND / OR of `"v" in tags`); `equivalent` adds a diff --git a/internal/filter/functions_test.go b/internal/filter/functions_test.go index 6a9440712..9406a43b8 100644 --- a/internal/filter/functions_test.go +++ b/internal/filter/functions_test.go @@ -3,6 +3,7 @@ package filter import ( "context" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -107,6 +108,190 @@ func TestCompileTimestampAccessorsPerDialect(t *testing.T) { } } +func TestCompileNowAccessorsFoldToInjectedClock(t *testing.T) { + t.Parallel() + + // 2026-07-07T10:30:45Z, a Tuesday (year day 188). + engine := memoEngineAt(t, time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix()) + + cases := []struct { + name string + filter string + args []any + }{ + {"on this day", `created_ts.getMonth() == now.getMonth() && created_ts.getDate() == now.getDate()`, []any{int64(6), int64(7)}}, + {"year", `created_ts.getFullYear() < now.getFullYear()`, []any{int64(2026)}}, + {"day of month", `created_ts.getDayOfMonth() == now.getDayOfMonth()`, []any{int64(6)}}, + {"day of week", `created_ts.getDayOfWeek() == now.getDayOfWeek()`, []any{int64(2)}}, + {"day of year", `created_ts.getDayOfYear() == now.getDayOfYear()`, []any{int64(187)}}, + {"clock parts", `created_ts.getHours() == now.getHours() || created_ts.getMinutes() == now.getMinutes() || created_ts.getSeconds() == now.getSeconds()`, []any{int64(10), int64(30), int64(45)}}, + } + for _, tc := range cases { + stmt, err := engine.CompileToStatement(context.Background(), tc.filter, RenderOptions{Dialect: DialectSQLite}) + require.NoError(t, err, tc.name) + require.Equal(t, tc.args, stmt.Args, tc.name) + } +} + +func TestCompileNowAccessorRejectsTimezoneArg(t *testing.T) { + t.Parallel() + + engine, err := NewEngine(NewSchema()) + require.NoError(t, err) + _, err = engine.Compile(context.Background(), `created_ts.getMonth() == now.getMonth("America/New_York")`) + require.Error(t, err) +} + +func TestCompileOnThisDayPerDialect(t *testing.T) { + t.Parallel() + + const onThisDay = `created_ts.getMonth() == now.getMonth() && created_ts.getDate() == now.getDate()` + engine := memoEngineAt(t, time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix()) + + cases := []struct { + dialect DialectName + fragments []string + }{ + {DialectSQLite, []string{"strftime('%m'", "strftime('%d'", "'unixepoch'"}}, + {DialectPostgres, []string{"EXTRACT(MONTH FROM", "EXTRACT(DAY FROM"}}, + {DialectMySQL, []string{"MONTH(`memo`.`created_ts`)", "DAYOFMONTH(`memo`.`created_ts`)"}}, + } + for _, tc := range cases { + stmt, err := engine.CompileToStatement(context.Background(), onThisDay, RenderOptions{Dialect: tc.dialect}) + require.NoError(t, err, tc.dialect) + for _, frag := range tc.fragments { + require.Contains(t, stmt.SQL, frag, "dialect %s", tc.dialect) + } + require.Equal(t, []any{int64(6), int64(7)}, stmt.Args, "dialect %s", tc.dialect) + } +} + +func TestCompileNowAccessorsAtBoundaryDates(t *testing.T) { + t.Parallel() + + // Every accessor folded at calendar edges: year rollover, leap day, Sunday. + allAccessors := `created_ts.getFullYear() == now.getFullYear() ` + + `&& created_ts.getMonth() == now.getMonth() ` + + `&& created_ts.getDate() == now.getDate() ` + + `&& created_ts.getDayOfMonth() == now.getDayOfMonth() ` + + `&& created_ts.getDayOfWeek() == now.getDayOfWeek() ` + + `&& created_ts.getDayOfYear() == now.getDayOfYear() ` + + `&& created_ts.getHours() == now.getHours() ` + + `&& created_ts.getMinutes() == now.getMinutes() ` + + `&& created_ts.getSeconds() == now.getSeconds()` + + cases := []struct { + name string + clock time.Time + // year, month, date, dayOfMonth, dayOfWeek, dayOfYear, hours, minutes, seconds + args []any + }{ + { + "new year's eve", + time.Date(2026, time.December, 31, 23, 59, 59, 0, time.UTC), + []any{int64(2026), int64(11), int64(31), int64(30), int64(4), int64(364), int64(23), int64(59), int64(59)}, + }, + { + "new year's day", + time.Date(2027, time.January, 1, 0, 0, 0, 0, time.UTC), + []any{int64(2027), int64(0), int64(1), int64(0), int64(5), int64(0), int64(0), int64(0), int64(0)}, + }, + { + "leap day", + time.Date(2028, time.February, 29, 12, 0, 0, 0, time.UTC), + []any{int64(2028), int64(1), int64(29), int64(28), int64(2), int64(59), int64(12), int64(0), int64(0)}, + }, + { + "sunday", + time.Date(2026, time.July, 5, 8, 15, 30, 0, time.UTC), + []any{int64(2026), int64(6), int64(5), int64(4), int64(0), int64(185), int64(8), int64(15), int64(30)}, + }, + } + for _, tc := range cases { + engine := memoEngineAt(t, tc.clock.Unix()) + stmt, err := engine.CompileToStatement(context.Background(), allAccessors, RenderOptions{Dialect: DialectSQLite}) + require.NoError(t, err, tc.name) + require.Equal(t, tc.args, stmt.Args, tc.name) + } +} + +func TestCompileNowAccessorFoldsInUTC(t *testing.T) { + t.Parallel() + + // A clock in UTC+8 at 05:00 on July 8 is still July 7, 21:00 in UTC; + // folding must not leak the clock's zone. + engine, err := NewEngine(NewSchema()) + require.NoError(t, err) + engine.nowFunc = func() time.Time { + return time.Date(2026, time.July, 8, 5, 0, 0, 0, time.FixedZone("UTC+8", 8*3600)) + } + + stmt, err := engine.CompileToStatement( + context.Background(), + `created_ts.getDate() == now.getDate() && created_ts.getHours() == now.getHours()`, + RenderOptions{Dialect: DialectSQLite}, + ) + require.NoError(t, err) + require.Equal(t, []any{int64(7), int64(21)}, stmt.Args) +} + +func TestCompileNowAccessorOnLeftSide(t *testing.T) { + t.Parallel() + + engine := memoEngineAt(t, time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix()) + stmt, err := engine.CompileToStatement(context.Background(), `now.getMonth() == created_ts.getMonth()`, RenderOptions{Dialect: DialectSQLite}) + require.NoError(t, err) + require.Contains(t, stmt.SQL, "strftime('%m'") + require.Equal(t, []any{int64(6)}, stmt.Args) +} + +func TestCompileNowAccessorMirrorsOrderingOperator(t *testing.T) { + t.Parallel() + + // Swapping the literal to the right must flip < to > to keep the meaning. + engine := memoEngineAt(t, time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix()) + stmt, err := engine.CompileToStatement(context.Background(), `now.getHours() < created_ts.getHours()`, RenderOptions{Dialect: DialectSQLite}) + require.NoError(t, err) + require.Contains(t, stmt.SQL, "> ?") + require.Equal(t, []any{int64(10)}, stmt.Args) +} + +func TestCompileNowAccessorAgainstLiteralFoldsToConstant(t *testing.T) { + t.Parallel() + + // Both sides fold to literals; the comparison folds to a constant condition. + engine := memoEngineAt(t, time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix()) + + // True → trivial filter (empty SQL, matches everything). + stmt, err := engine.CompileToStatement(context.Background(), `now.getFullYear() >= 2026`, RenderOptions{Dialect: DialectSQLite}) + require.NoError(t, err) + require.Empty(t, stmt.SQL) + require.Empty(t, stmt.Args) + + // False → unsatisfiable filter. + stmt, err = engine.CompileToStatement(context.Background(), `now.getFullYear() < 2026`, RenderOptions{Dialect: DialectSQLite}) + require.NoError(t, err) + require.Equal(t, "1 = 0", stmt.SQL) + require.Empty(t, stmt.Args) +} + +func TestCompileAttachmentNowAccessors(t *testing.T) { + t.Parallel() + + engine, err := NewEngine(NewAttachmentSchema()) + require.NoError(t, err) + engine.nowFunc = fixedClock(time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix()) + + stmt, err := engine.CompileToStatement( + context.Background(), + `create_time.getMonth() == now.getMonth() && create_time.getDate() == now.getDate()`, + RenderOptions{Dialect: DialectSQLite}, + ) + require.NoError(t, err) + require.Contains(t, stmt.SQL, "`attachment`.`created_ts`") + require.Equal(t, []any{int64(6), int64(7)}, stmt.Args) +} + func TestCompileTimestampAccessorRejectsTimezoneArg(t *testing.T) { t.Parallel() diff --git a/internal/filter/parser.go b/internal/filter/parser.go index 3ef879009..f6a26a2f9 100644 --- a/internal/filter/parser.go +++ b/internal/filter/parser.go @@ -134,6 +134,21 @@ func buildComparisonCondition(call *exprv1.Expr_Call, pc parseContext) (Conditio return nil, err } + // The renderer expects a field/function/accessor on the left. A folded + // literal on the left (e.g. now.getMonth() == created_ts.getMonth()) swaps + // operands; two literals fold to a constant outcome. + if leftLit, ok := left.(*LiteralValue); ok { + if rightLit, ok := right.(*LiteralValue); ok { + outcome, err := compareLiterals(leftLit.Value, op, rightLit.Value) + if err != nil { + return nil, err + } + return &ConstantCondition{Value: outcome}, nil + } + left, right = right, left + op = mirrorComparisonOperator(op) + } + // If the left side is a field, validate allowed operators. if field, ok := left.(*FieldRef); ok { def, exists := pc.schema.Field(field.Name) @@ -160,6 +175,90 @@ func buildComparisonCondition(call *exprv1.Expr_Call, pc parseContext) (Conditio }, nil } +// mirrorComparisonOperator flips an operator so swapped operands keep the +// original meaning (a < b ⇔ b > a). +func mirrorComparisonOperator(op ComparisonOperator) ComparisonOperator { + switch op { + case CompareLt: + return CompareGt + case CompareLte: + return CompareGte + case CompareGt: + return CompareLt + case CompareGte: + return CompareLte + default: + return op + } +} + +// compareLiterals evaluates a comparison whose operands both folded to +// constants (e.g. now.getFullYear() >= 2026) into a boolean outcome. +func compareLiterals(left any, op ComparisonOperator, right any) (bool, error) { + if l, r, ok := asFloats(left, right); ok { + switch op { + case CompareEq: + return l == r, nil + case CompareNeq: + return l != r, nil + case CompareLt: + return l < r, nil + case CompareLte: + return l <= r, nil + case CompareGt: + return l > r, nil + case CompareGte: + return l >= r, nil + } + } + if l, ok := left.(string); ok { + if r, ok := right.(string); ok { + switch op { + case CompareEq: + return l == r, nil + case CompareNeq: + return l != r, nil + case CompareLt: + return l < r, nil + case CompareLte: + return l <= r, nil + case CompareGt: + return l > r, nil + case CompareGte: + return l >= r, nil + } + } + } + if l, ok := left.(bool); ok { + if r, ok := right.(bool); ok { + switch op { + case CompareEq: + return l == r, nil + case CompareNeq: + return l != r, nil + } + } + } + return false, errors.Errorf("unsupported constant comparison %T %s %T", left, op, right) +} + +// asFloats widens both operands to float64 when each is numeric. +func asFloats(left, right any) (float64, float64, bool) { + l, lok := toFloat(left) + r, rok := toFloat(right) + return l, r, lok && rok +} + +func toFloat(v any) (float64, bool) { + switch x := v.(type) { + case int64: + return float64(x), true + case float64: + return x, true + } + return 0, false +} + func buildInCondition(call *exprv1.Expr_Call, pc parseContext) (Condition, error) { if len(call.Args) != 2 { return nil, errors.New("in operator expects two arguments") @@ -306,7 +405,7 @@ func buildValueExpr(expr *exprv1.Expr, pc parseContext) (ValueExpr, error) { if call := expr.GetCallExpr(); call != nil { if call.Target != nil && isTimestampAccessor(call.Function) { - return buildTimestampAccessor(call, pc.schema) + return buildTimestampAccessor(call, pc) } switch call.Function { case "size": @@ -549,25 +648,58 @@ func isTimestampAccessor(name string) bool { } // buildTimestampAccessor converts created_ts.getMonth() into a FieldAccessorValue. +// A `now` target folds to a literal date part of the frozen evaluation time, so +// expressions like created_ts.getMonth() == now.getMonth() stay dynamic per compile. // Timezone arguments are rejected; extraction is UTC (see renderer). -func buildTimestampAccessor(call *exprv1.Expr_Call, schema Schema) (ValueExpr, error) { +func buildTimestampAccessor(call *exprv1.Expr_Call, pc parseContext) (ValueExpr, error) { targetName, err := getIdentName(call.Target) if err != nil { return nil, errors.Wrap(err, "timestamp accessor requires a field target") } - field, ok := schema.Field(targetName) + if len(call.Args) != 0 { + return nil, errors.Errorf("%s() with a timezone argument is not supported", call.Function) + } + if targetName == "now" { + return &LiteralValue{Value: foldNowAccessor(call.Function, pc.now)}, nil + } + field, ok := pc.schema.Field(targetName) if !ok { return nil, errors.Errorf("unknown identifier %q", targetName) } if field.Type != FieldTypeTimestamp { return nil, errors.Errorf("%s() is only valid on timestamp fields, got %q", call.Function, targetName) } - if len(call.Args) != 0 { - return nil, errors.Errorf("%s() with a timezone argument is not supported", call.Function) - } return &FieldAccessorValue{Field: targetName, Accessor: call.Function}, nil } +// foldNowAccessor evaluates a timestamp accessor against the frozen evaluation +// time in UTC, matching CEL result bases (0-based month, day-of-month, day-of-week +// with 0 = Sunday, and day-of-year; 1-based getDate). +func foldNowAccessor(accessor string, now time.Time) int64 { + t := now.UTC() + switch accessor { + case "getFullYear": + return int64(t.Year()) + case "getMonth": + return int64(t.Month()) - 1 + case "getDate": + return int64(t.Day()) + case "getDayOfMonth": + return int64(t.Day()) - 1 + case "getDayOfWeek": + return int64(t.Weekday()) + case "getDayOfYear": + return int64(t.YearDay()) - 1 + case "getHours": + return int64(t.Hour()) + case "getMinutes": + return int64(t.Minute()) + case "getSeconds": + return int64(t.Second()) + } + return 0 +} + // buildSetCondition desugars ext.Sets() operations over a JSON list field into // existing IR: membership reduces to ElementInCondition, and equivalence adds a // length check. This relies on the list field being a set (no duplicates), which