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.
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
+138
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user