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.
pull/6080/head
boojack 4 days ago
parent e3c231fcac
commit 41ff22b0cc

@ -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

@ -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()

@ -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

Loading…
Cancel
Save