diff --git a/plugin/filter/filter.go b/plugin/filter/filter.go index be1d49eb..cdb13955 100644 --- a/plugin/filter/filter.go +++ b/plugin/filter/filter.go @@ -17,6 +17,7 @@ var MemoFilterCELAttributes = []cel.EnvOption{ cel.Variable("tag", cel.StringType), cel.Variable("update_time", cel.StringType), cel.Variable("visibility", cel.StringType), + cel.Variable("has_task_list", cel.BoolType), } // Parse parses the filter string and returns the parsed expression. diff --git a/store/db/mysql/memo_filter.go b/store/db/mysql/memo_filter.go index dcdfb63d..0f166b10 100644 --- a/store/db/mysql/memo_filter.go +++ b/store/db/mysql/memo_filter.go @@ -59,7 +59,7 @@ func (d *DB) ConvertExprToSQL(ctx *filter.ConvertContext, expr *exprv1.Expr) err if err != nil { return err } - if !slices.Contains([]string{"creator_id", "create_time", "update_time", "visibility", "content"}, identifier) { + if !slices.Contains([]string{"creator_id", "create_time", "update_time", "visibility", "content", "has_task_list"}, identifier) { return errors.Errorf("invalid identifier for %s", v.CallExpr.Function) } value, err := filter.GetConstValue(v.CallExpr.Args[1]) @@ -138,6 +138,25 @@ func (d *DB) ConvertExprToSQL(ctx *filter.ConvertContext, expr *exprv1.Expr) err return err } ctx.Args = append(ctx.Args, valueInt) + } else if identifier == "has_task_list" { + if operator != "=" && operator != "!=" { + return errors.Errorf("invalid operator for %s", v.CallExpr.Function) + } + valueBool, ok := value.(bool) + if !ok { + return errors.New("invalid boolean value for has_task_list") + } + + // In MySQL, we can use JSON_EXTRACT to get the value and compare it to 'true' or 'false' + compareValue := "false" + if valueBool { + compareValue = "true" + } + + // MySQL uses -> as a shorthand for JSON_EXTRACT + if _, err := ctx.Buffer.WriteString(fmt.Sprintf("JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') %s CAST('%s' AS JSON)", operator, compareValue)); err != nil { + return err + } } case "@in": if len(v.CallExpr.Args) != 2 { @@ -207,13 +226,17 @@ func (d *DB) ConvertExprToSQL(ctx *filter.ConvertContext, expr *exprv1.Expr) err } } else if v, ok := expr.ExprKind.(*exprv1.Expr_IdentExpr); ok { identifier := v.IdentExpr.GetName() - if !slices.Contains([]string{"pinned"}, identifier) { + if !slices.Contains([]string{"pinned", "has_task_list"}, identifier) { return errors.Errorf("invalid identifier for %s", identifier) } if identifier == "pinned" { if _, err := ctx.Buffer.WriteString("`memo`.`pinned` IS TRUE"); err != nil { return err } + } else if identifier == "has_task_list" { + if _, err := ctx.Buffer.WriteString("JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') = CAST('true' AS JSON)"); err != nil { + return err + } } } return nil diff --git a/store/db/mysql/memo_filter_test.go b/store/db/mysql/memo_filter_test.go index f904590a..de7ad30e 100644 --- a/store/db/mysql/memo_filter_test.go +++ b/store/db/mysql/memo_filter_test.go @@ -59,6 +59,41 @@ func TestConvertExprToSQL(t *testing.T) { want: "`memo`.`pinned` IS TRUE", args: []any{}, }, + { + filter: `has_task_list`, + want: "JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') = CAST('true' AS JSON)", + args: []any{}, + }, + { + filter: `has_task_list == true`, + want: "JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') = CAST('true' AS JSON)", + args: []any{}, + }, + { + filter: `has_task_list != false`, + want: "JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') != CAST('false' AS JSON)", + args: []any{}, + }, + { + filter: `has_task_list == false`, + want: "JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') = CAST('false' AS JSON)", + args: []any{}, + }, + { + filter: `!has_task_list`, + want: "NOT (JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') = CAST('true' AS JSON))", + args: []any{}, + }, + { + filter: `has_task_list && pinned`, + want: "(JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') = CAST('true' AS JSON) AND `memo`.`pinned` IS TRUE)", + args: []any{}, + }, + { + filter: `has_task_list && content.contains("todo")`, + want: "(JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') = CAST('true' AS JSON) AND `memo`.`content` LIKE ?)", + args: []any{"%todo%"}, + }, } for _, tt := range tests { diff --git a/store/db/postgres/memo_filter.go b/store/db/postgres/memo_filter.go index f9e66929..233693a9 100644 --- a/store/db/postgres/memo_filter.go +++ b/store/db/postgres/memo_filter.go @@ -59,7 +59,7 @@ func (d *DB) ConvertExprToSQL(ctx *filter.ConvertContext, expr *exprv1.Expr) err if err != nil { return err } - if !slices.Contains([]string{"creator_id", "create_time", "update_time", "visibility", "content"}, identifier) { + if !slices.Contains([]string{"creator_id", "create_time", "update_time", "visibility", "content", "has_task_list"}, identifier) { return errors.Errorf("invalid identifier for %s", v.CallExpr.Function) } value, err := filter.GetConstValue(v.CallExpr.Args[1]) @@ -135,6 +135,20 @@ func (d *DB) ConvertExprToSQL(ctx *filter.ConvertContext, expr *exprv1.Expr) err return err } ctx.Args = append(ctx.Args, valueInt) + } else if identifier == "has_task_list" { + if operator != "=" && operator != "!=" { + return errors.Errorf("invalid operator for %s", v.CallExpr.Function) + } + valueBool, ok := value.(bool) + if !ok { + return errors.New("invalid boolean value for has_task_list") + } + + // In PostgreSQL, extract the boolean from the JSON and compare it + if _, err := ctx.Buffer.WriteString(fmt.Sprintf("(memo.payload->'property'->>'hasTaskList')::boolean %s %s", operator, placeholder(len(ctx.Args)+ctx.ArgsOffset+1))); err != nil { + return err + } + ctx.Args = append(ctx.Args, valueBool) } case "@in": if len(v.CallExpr.Args) != 2 { @@ -204,13 +218,17 @@ func (d *DB) ConvertExprToSQL(ctx *filter.ConvertContext, expr *exprv1.Expr) err } } else if v, ok := expr.ExprKind.(*exprv1.Expr_IdentExpr); ok { identifier := v.IdentExpr.GetName() - if !slices.Contains([]string{"pinned"}, identifier) { + if !slices.Contains([]string{"pinned", "has_task_list"}, identifier) { return errors.Errorf("invalid identifier %s", identifier) } if identifier == "pinned" { if _, err := ctx.Buffer.WriteString("memo.pinned IS TRUE"); err != nil { return err } + } else if identifier == "has_task_list" { + if _, err := ctx.Buffer.WriteString("(memo.payload->'property'->>'hasTaskList')::boolean IS TRUE"); err != nil { + return err + } } } return nil diff --git a/store/db/postgres/memo_filter_test.go b/store/db/postgres/memo_filter_test.go index 98cd9874..ad659cfb 100644 --- a/store/db/postgres/memo_filter_test.go +++ b/store/db/postgres/memo_filter_test.go @@ -59,6 +59,41 @@ func TestRestoreExprToSQL(t *testing.T) { want: "memo.pinned IS TRUE", args: []any{}, }, + { + filter: `has_task_list`, + want: "(memo.payload->'property'->>'hasTaskList')::boolean IS TRUE", + args: []any{}, + }, + { + filter: `has_task_list == true`, + want: "(memo.payload->'property'->>'hasTaskList')::boolean = $1", + args: []any{true}, + }, + { + filter: `has_task_list != false`, + want: "(memo.payload->'property'->>'hasTaskList')::boolean != $1", + args: []any{false}, + }, + { + filter: `has_task_list == false`, + want: "(memo.payload->'property'->>'hasTaskList')::boolean = $1", + args: []any{false}, + }, + { + filter: `!has_task_list`, + want: "NOT ((memo.payload->'property'->>'hasTaskList')::boolean IS TRUE)", + args: []any{}, + }, + { + filter: `has_task_list && pinned`, + want: "((memo.payload->'property'->>'hasTaskList')::boolean IS TRUE AND memo.pinned IS TRUE)", + args: []any{}, + }, + { + filter: `has_task_list && content.contains("todo")`, + want: "((memo.payload->'property'->>'hasTaskList')::boolean IS TRUE AND memo.content ILIKE $1)", + args: []any{"%todo%"}, + }, } for _, tt := range tests { diff --git a/store/db/sqlite/memo_filter.go b/store/db/sqlite/memo_filter.go index 452272ea..eeb597f8 100644 --- a/store/db/sqlite/memo_filter.go +++ b/store/db/sqlite/memo_filter.go @@ -59,7 +59,7 @@ func (d *DB) ConvertExprToSQL(ctx *filter.ConvertContext, expr *exprv1.Expr) err if err != nil { return err } - if !slices.Contains([]string{"creator_id", "create_time", "update_time", "visibility", "content"}, identifier) { + if !slices.Contains([]string{"creator_id", "create_time", "update_time", "visibility", "content", "has_task_list"}, identifier) { return errors.Errorf("invalid identifier for %s", v.CallExpr.Function) } value, err := filter.GetConstValue(v.CallExpr.Args[1]) @@ -138,6 +138,22 @@ func (d *DB) ConvertExprToSQL(ctx *filter.ConvertContext, expr *exprv1.Expr) err return err } ctx.Args = append(ctx.Args, valueInt) + } else if identifier == "has_task_list" { + if operator != "=" && operator != "!=" { + return errors.Errorf("invalid operator for %s", v.CallExpr.Function) + } + valueBool, ok := value.(bool) + if !ok { + return errors.New("invalid boolean value for has_task_list") + } + // In SQLite JSON boolean values are 1 for true and 0 for false + compareValue := 0 + if valueBool { + compareValue = 1 + } + if _, err := ctx.Buffer.WriteString(fmt.Sprintf("JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') %s %d", operator, compareValue)); err != nil { + return err + } } case "@in": if len(v.CallExpr.Args) != 2 { @@ -207,13 +223,18 @@ func (d *DB) ConvertExprToSQL(ctx *filter.ConvertContext, expr *exprv1.Expr) err } } else if v, ok := expr.ExprKind.(*exprv1.Expr_IdentExpr); ok { identifier := v.IdentExpr.GetName() - if !slices.Contains([]string{"pinned"}, identifier) { + if !slices.Contains([]string{"pinned", "has_task_list"}, identifier) { return errors.Errorf("invalid identifier %s", identifier) } if identifier == "pinned" { if _, err := ctx.Buffer.WriteString("`memo`.`pinned` IS TRUE"); err != nil { return err } + } else if identifier == "has_task_list" { + // Handle has_task_list as a standalone boolean identifier + if _, err := ctx.Buffer.WriteString("JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') IS TRUE"); err != nil { + return err + } } } return nil diff --git a/store/db/sqlite/memo_filter_test.go b/store/db/sqlite/memo_filter_test.go index c9f3f3a0..522eb54a 100644 --- a/store/db/sqlite/memo_filter_test.go +++ b/store/db/sqlite/memo_filter_test.go @@ -74,6 +74,41 @@ func TestConvertExprToSQL(t *testing.T) { want: "(`memo`.`creator_id` = ? OR `memo`.`visibility` IN (?,?))", args: []any{int64(101), "PUBLIC", "PRIVATE"}, }, + { + filter: `has_task_list`, + want: "JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') IS TRUE", + args: []any{}, + }, + { + filter: `has_task_list == true`, + want: "JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') = 1", + args: []any{}, + }, + { + filter: `has_task_list != false`, + want: "JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') != 0", + args: []any{}, + }, + { + filter: `has_task_list == false`, + want: "JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') = 0", + args: []any{}, + }, + { + filter: `!has_task_list`, + want: "NOT (JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') IS TRUE)", + args: []any{}, + }, + { + filter: `has_task_list && pinned`, + want: "(JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') IS TRUE AND `memo`.`pinned` IS TRUE)", + args: []any{}, + }, + { + filter: `has_task_list && content.contains("todo")`, + want: "(JSON_EXTRACT(`memo`.`payload`, '$.property.hasTaskList') IS TRUE AND `memo`.`content` LIKE ?)", + args: []any{"%todo%"}, + }, } for _, tt := range tests {