feat(filter): expand CEL filter surface with startsWith/endsWith, matches(), and all()

Let users write three more CEL constructs in the filter field, each compiled to
SQL across SQLite/MySQL/Postgres:

- Scalar startsWith()/endsWith() on content/filename/mime_type (case-insensitive)
- matches() regex: PG ~, MySQL/SQLite REGEXP (Go-backed SQLite fn), validated at
  compile time via cel.ValidateRegexLiterals()
- all() comprehension over tags via per-element subqueries, non-empty required

Also: contains() now escapes LIKE metacharacters (%, _, \); cross-dialect render
tests plus behavioral tests; cel-go bumped to v0.28.1; new operators surfaced in
the frontend shortcut guide.
This commit is contained in:
boojack
2026-06-15 23:22:28 +08:00
parent 817561df8f
commit f0e4a5624f
16 changed files with 1835 additions and 34 deletions
@@ -0,0 +1,887 @@
# CEL Filter Surface Expansion — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users write three more CEL constructs in the `filter` field — scalar `startsWith()`/`endsWith()` (case-insensitive), `matches(regex)`, and `all()` over tags (non-empty) — each compiled to SQL across SQLite/MySQL/Postgres.
**Architecture:** The `internal/filter` engine parses CEL with `cel-go`, walks the AST into a dialect-agnostic IR (`ir.go`), and renders dialect SQL (`render.go`). cel-go never evaluates — every feature must become a SQL `WHERE` fragment. We add IR nodes + parser recognition + per-dialect rendering, and register a Go-backed `REGEXP` function for SQLite (which has no built-in one).
**Tech Stack:** Go, `github.com/google/cel-go v0.28.0`, `modernc.org/sqlite` (pure-Go), `github.com/stretchr/testify/require`.
**Spec:** `docs/superpowers/specs/2026-06-15-cel-filter-surface-expansion-design.md`
---
## File structure
| File | Change | Responsibility |
|------|--------|----------------|
| `internal/filter/ir.go` | Modify | Replace `ContainsCondition` with `TextMatchCondition`; add `RegexCondition`; add `ComprehensionAll` kind |
| `internal/filter/parser.go` | Modify | Recognize top-level `contains`/`startsWith`/`endsWith`/`matches`; accept `all()` comprehension |
| `internal/filter/render.go` | Modify | Render text-match (LIKE), regex, and `all()` per-element subqueries; shared `foldedLike`/`likePattern`/`escapeLikeLiteral` helpers |
| `internal/filter/schema.go` | Modify | Add `cel.ValidateRegexLiterals()` validator; enable text matching on attachment `mime_type` |
| `internal/filter/engine_test.go` | Modify | Compile-level accept/reject unit tests |
| `store/db/sqlite/functions.go` | Modify | Register a Go-backed `regexp(pattern, value)` scalar function with a compiled-pattern cache |
| `store/db/sqlite/sqlite.go` | Modify | Call `ensureRegexpRegistered()` in `NewDB` |
| `store/test/memo_filter_test.go` | Modify | Behavioral tests for the new memo filters |
| `store/test/attachment_filter_test.go` | Modify | Behavioral tests for `filename`/`mime_type` |
| `internal/filter/README.md` | Modify | Document new syntax + regex cross-dialect caveat |
**Key design choices locked in:**
- The existing `Field.SupportsContains` flag is **reused** as the gate for *all* text-matching ops (`contains`/`startsWith`/`endsWith`/`matches`) — no rename, lower risk. We just enable it on `mime_type`.
- New scalar `startsWith`/`endsWith`/`contains` are **case-insensitive** (reuse the existing `memos_unicode_lower` / `ILIKE` machinery). `matches()` and `==` are case-sensitive.
- `all()` over a memo with zero tags does **not** match (non-empty guard).
- LIKE patterns escape `%` `_` `\`; only SQLite needs an explicit `ESCAPE '\'` clause (Postgres/MySQL default the escape char to backslash, and patterns are passed as bound parameters so no SQL-literal backslash hazard).
**Suggested task order** lands the two cheap features (text-match refactor, scalar prefix/suffix, regex) before the heavy `all()` work, giving a natural stop point. `all()` (Task 5) is the largest piece and could be deferred to a follow-up if needed.
---
### Task 1: Refactor `ContainsCondition` → `TextMatchCondition` (+ LIKE escaping)
Foundation refactor. No new user-facing behavior except that LIKE metacharacters in `contains()` values are now treated literally. Existing tests must stay green.
**Files:**
- Modify: `internal/filter/ir.go` (replace `ContainsCondition`)
- Modify: `internal/filter/parser.go` (`buildContainsCondition` → shared builder)
- Modify: `internal/filter/render.go` (`renderContainsCondition``renderTextMatch` + helpers)
- [ ] **Step 1: Add a failing escaping test** in `internal/filter/engine_test.go`
```go
func TestCompileContainsEscapesLikeWildcards(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
stmt, err := engine.CompileToStatement(context.Background(), `content.contains("50%_off")`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
// The % and _ in the value must be escaped so they are matched literally,
// and SQLite needs an explicit ESCAPE clause.
require.Contains(t, stmt.SQL, `ESCAPE '\'`)
require.Equal(t, []any{`%50\%\_off%`}, stmt.Args)
}
```
- [ ] **Step 2: Run it to verify it fails**
Run: `go test ./internal/filter/ -run TestCompileContainsEscapesLikeWildcards -v`
Expected: FAIL (current renderer emits `%50%_off%` with no `ESCAPE`).
- [ ] **Step 3: Replace `ContainsCondition` in `internal/filter/ir.go`**
Delete the `ContainsCondition` struct + its `isCondition()` (lines ~76-82) and add:
```go
// TextMatchMode enumerates LIKE-based string match modes.
type TextMatchMode string
const (
TextMatchContains TextMatchMode = "contains"
TextMatchPrefix TextMatchMode = "prefix"
TextMatchSuffix TextMatchMode = "suffix"
)
// TextMatchCondition models a case-insensitive LIKE match on a scalar string field
// (content.contains/startsWith/endsWith).
type TextMatchCondition struct {
Field string
Mode TextMatchMode
Value string
}
func (*TextMatchCondition) isCondition() {}
```
- [ ] **Step 4: Update the parser in `internal/filter/parser.go`**
In `buildCallCondition`, replace the `case "contains":` line with:
```go
case "contains":
return buildTextMatchCondition(call, schema, TextMatchContains)
```
Delete `buildContainsCondition` (lines ~196-227) and add:
```go
func buildTextMatchCondition(call *exprv1.Expr_Call, schema Schema, mode TextMatchMode) (Condition, error) {
if call.Target == nil {
return nil, errors.New("text match requires a target")
}
targetName, err := getIdentName(call.Target)
if err != nil {
return nil, err
}
field, ok := schema.Field(targetName)
if !ok {
return nil, errors.Errorf("unknown identifier %q", targetName)
}
if !field.SupportsContains {
return nil, errors.Errorf("identifier %q does not support text matching", targetName)
}
if len(call.Args) != 1 {
return nil, errors.New("text match expects exactly one argument")
}
value, err := getConstValue(call.Args[0])
if err != nil {
return nil, errors.Wrap(err, "text match only supports literal arguments")
}
str, ok := value.(string)
if !ok {
return nil, errors.New("text match argument must be a string")
}
return &TextMatchCondition{Field: targetName, Mode: mode, Value: str}, nil
}
```
- [ ] **Step 5: Update the renderer in `internal/filter/render.go`**
In `renderCondition`, replace `case *ContainsCondition:` / `return r.renderContainsCondition(c)` with:
```go
case *TextMatchCondition:
return r.renderTextMatch(c)
```
Delete `renderContainsCondition` (lines ~449-469) and add:
```go
func (r *renderer) renderTextMatch(cond *TextMatchCondition) (renderResult, error) {
field, ok := r.schema.Field(cond.Field)
if !ok {
return renderResult{}, errors.Errorf("unknown field %q", cond.Field)
}
column := field.columnExpr(r.dialect)
pattern := likePattern(cond.Mode, cond.Value)
return renderResult{sql: r.foldedLike(column, pattern)}, nil
}
// foldedLike renders a case-insensitive LIKE comparison of colExpr against a
// (already metacharacter-escaped) pattern, using each dialect's case-folding.
func (r *renderer) foldedLike(colExpr, pattern string) string {
switch r.dialect {
case DialectSQLite:
// memos_unicode_lower gives Unicode-aware folding; ESCAPE '\' is required
// because SQLite has no default LIKE escape character.
return fmt.Sprintf(`memos_unicode_lower(%s) LIKE memos_unicode_lower(%s) ESCAPE '\'`, colExpr, r.addArg(pattern))
case DialectPostgres:
// ILIKE is case-insensitive; backslash is the default escape character.
return fmt.Sprintf("%s ILIKE %s", colExpr, r.addArg(pattern))
default: // MySQL: default collation is case-insensitive; backslash is the default escape.
return fmt.Sprintf("%s LIKE %s", colExpr, r.addArg(pattern))
}
}
// likePattern escapes LIKE metacharacters in value and wraps it for the mode.
func likePattern(mode TextMatchMode, value string) string {
escaped := escapeLikeLiteral(value)
switch mode {
case TextMatchPrefix:
return escaped + "%"
case TextMatchSuffix:
return "%" + escaped
default:
return "%" + escaped + "%"
}
}
// escapeLikeLiteral escapes the LIKE metacharacters \, %, and _ so user input
// is matched literally. Backslash is the escape character on all three dialects.
func escapeLikeLiteral(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
```
- [ ] **Step 6: Run the new test + existing suites to verify green**
Run: `go test ./internal/filter/ -v`
Expected: PASS (including `TestCompileContainsEscapesLikeWildcards`).
Run: `go test ./store/test/ -run TestMemoFilterContent -v`
Expected: PASS (existing `contains` behavioral tests, including special-characters/unicode, still pass).
- [ ] **Step 7: Commit**
```bash
git add internal/filter/ir.go internal/filter/parser.go internal/filter/render.go internal/filter/engine_test.go
git commit -m "refactor(filter): unify string matching into TextMatchCondition with LIKE escaping
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Scalar `startsWith()` / `endsWith()`
Wire the new prefix/suffix modes through the parser and enable text matching on attachment `mime_type`. Rendering already exists from Task 1.
**Files:**
- Modify: `internal/filter/parser.go` (add `startsWith`/`endsWith` cases)
- Modify: `internal/filter/schema.go` (enable `SupportsContains` on `mime_type`)
- Modify: `store/test/memo_filter_test.go`, `store/test/attachment_filter_test.go` (behavioral tests)
- Modify: `internal/filter/engine_test.go` (reject on unsupported field)
- [ ] **Step 1: Add failing behavioral tests** in `store/test/memo_filter_test.go`
```go
func TestMemoFilterContentStartsWith(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
tc.CreateMemo(NewMemoBuilder("memo-todo", tc.User.ID).Content("TODO: buy milk"))
tc.CreateMemo(NewMemoBuilder("memo-done", tc.User.ID).Content("Done with milk"))
// Prefix match, case-insensitive (consistent with contains()).
memos := tc.ListWithFilter(`content.startsWith("todo")`)
require.Len(t, memos, 1)
require.Equal(t, "memo-todo", memos[0].UID)
memos = tc.ListWithFilter(`content.startsWith("nope")`)
require.Len(t, memos, 0)
}
func TestMemoFilterContentEndsWith(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
tc.CreateMemo(NewMemoBuilder("memo-md", tc.User.ID).Content("notes.md"))
tc.CreateMemo(NewMemoBuilder("memo-txt", tc.User.ID).Content("notes.txt"))
memos := tc.ListWithFilter(`content.endsWith(".md")`)
require.Len(t, memos, 1)
require.Equal(t, "memo-md", memos[0].UID)
}
```
And in `store/test/attachment_filter_test.go`:
```go
func TestAttachmentFilterFilenameStartsWith(t *testing.T) {
t.Parallel()
tc := NewAttachmentFilterTestContextWithUser(t)
defer tc.Close()
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("invoice-2026.pdf").MimeType("application/pdf"))
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("photo.png").MimeType("image/png"))
got := tc.ListWithFilter(`filename.startsWith("invoice")`)
require.Len(t, got, 1)
require.Equal(t, "invoice-2026.pdf", got[0].Filename)
// mime_type prefix matching (newly enabled).
got = tc.ListWithFilter(`mime_type.startsWith("image/")`)
require.Len(t, got, 1)
require.Equal(t, "photo.png", got[0].Filename)
}
```
- [ ] **Step 2: Run to verify they fail**
Run: `go test ./store/test/ -run 'TestMemoFilterContentStartsWith|TestMemoFilterContentEndsWith|TestAttachmentFilterFilenameStartsWith' -v`
Expected: FAIL — `startsWith` hits `buildCallCondition`'s default branch ("unsupported call expression"), and `mime_type` is not yet text-matchable.
- [ ] **Step 3: Add parser cases** in `internal/filter/parser.go` `buildCallCondition`
Immediately after the `case "contains":` line, add:
```go
case "startsWith":
return buildTextMatchCondition(call, schema, TextMatchPrefix)
case "endsWith":
return buildTextMatchCondition(call, schema, TextMatchSuffix)
```
- [ ] **Step 4: Enable text matching on `mime_type`** in `internal/filter/schema.go`
In `NewAttachmentSchema`, add `SupportsContains: true` to the `mime_type` field entry:
```go
"mime_type": {
Name: "mime_type",
Kind: FieldKindScalar,
Type: FieldTypeString,
Column: Column{Table: "attachment", Name: "type"},
SupportsContains: true,
Expressions: map[DialectName]string{},
},
```
- [ ] **Step 5: Add a compile-reject unit test** in `internal/filter/engine_test.go`
```go
func TestCompileRejectsStartsWithOnUnsupportedField(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
_, err = engine.Compile(context.Background(), `visibility.startsWith("P")`)
require.Error(t, err)
require.Contains(t, err.Error(), "does not support text matching")
}
```
- [ ] **Step 6: Run tests to verify green**
Run: `go test ./internal/filter/ ./store/test/ -run 'StartsWith|EndsWith|TextMatch' -v`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add internal/filter/parser.go internal/filter/schema.go internal/filter/engine_test.go store/test/memo_filter_test.go store/test/attachment_filter_test.go
git commit -m "feat(filter): support startsWith()/endsWith() on scalar string fields
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: Register a SQLite `REGEXP` function
`modernc.org/sqlite` has no built-in `REGEXP`. SQLite desugars `X REGEXP Y` to `regexp(Y, X)`, so register a 2-arg `regexp(pattern, value)` scalar function backed by Go's `regexp`, mirroring `ensureUnicodeLowerRegistered`.
**Files:**
- Modify: `store/db/sqlite/functions.go`
- Modify: `store/db/sqlite/sqlite.go`
- Test: `store/db/sqlite/functions_test.go` (create)
- [ ] **Step 1: Write a failing test** — create `store/db/sqlite/functions_test.go`
```go
package sqlite
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestRegexpFunctionMatches(t *testing.T) {
require.NoError(t, ensureRegexpRegistered())
re, err := compileRegexp(`^v\d+$`)
require.NoError(t, err)
require.True(t, re.MatchString("v12"))
require.False(t, re.MatchString("version"))
// Caching returns the same compiled instance.
re2, err := compileRegexp(`^v\d+$`)
require.NoError(t, err)
require.Same(t, re, re2)
_, err = compileRegexp(`(`)
require.Error(t, err)
}
```
- [ ] **Step 2: Run to verify it fails**
Run: `go test ./store/db/sqlite/ -run TestRegexpFunctionMatches -v`
Expected: FAIL — `ensureRegexpRegistered`/`compileRegexp` undefined.
- [ ] **Step 3: Implement in `store/db/sqlite/functions.go`**
Add `"errors"` and `"regexp"` to the imports, then append:
```go
var (
registerRegexpOnce sync.Once
registerRegexpErr error
// regexpCache memoizes compiled patterns; keys are pattern strings.
regexpCache sync.Map
)
// ensureRegexpRegistered registers a Go-backed `regexp(pattern, value)` scalar
// function so SQLite's `value REGEXP pattern` operator works (modernc.org/sqlite
// has no built-in implementation). Patterns use Go's RE2 syntax. Registered once
// globally; safe to call multiple times.
func ensureRegexpRegistered() error {
registerRegexpOnce.Do(func() {
registerRegexpErr = msqlite.RegisterScalarFunction("regexp", 2, func(_ *msqlite.FunctionContext, args []driver.Value) (driver.Value, error) {
if len(args) != 2 || args[0] == nil || args[1] == nil {
return int64(0), nil
}
pattern, ok := args[0].(string)
if !ok {
return nil, errors.New("regexp pattern must be a string")
}
var value string
switch v := args[1].(type) {
case string:
value = v
case []byte:
value = string(v)
default:
return int64(0), nil
}
re, err := compileRegexp(pattern)
if err != nil {
return nil, err
}
if re.MatchString(value) {
return int64(1), nil
}
return int64(0), nil
})
})
return registerRegexpErr
}
// compileRegexp compiles and caches a RE2 pattern.
func compileRegexp(pattern string) (*regexp.Regexp, error) {
if cached, ok := regexpCache.Load(pattern); ok {
return cached.(*regexp.Regexp), nil
}
re, err := regexp.Compile(pattern)
if err != nil {
return nil, err
}
regexpCache.Store(pattern, re)
return re, nil
}
```
- [ ] **Step 4: Wire into `NewDB`** in `store/db/sqlite/sqlite.go`
Right after the `ensureUnicodeLowerRegistered()` block, add:
```go
if err := ensureRegexpRegistered(); err != nil {
return nil, errors.Wrap(err, "failed to register sqlite regexp function")
}
```
- [ ] **Step 5: Run to verify green**
Run: `go test ./store/db/sqlite/ -run TestRegexpFunctionMatches -v`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add store/db/sqlite/functions.go store/db/sqlite/sqlite.go store/db/sqlite/functions_test.go
git commit -m "feat(sqlite): register Go-backed REGEXP function
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: `matches(regex)` on string fields
Add the IR node, parser recognition, per-dialect rendering, and the compile-time regex validator.
**Files:**
- Modify: `internal/filter/ir.go` (add `RegexCondition`)
- Modify: `internal/filter/parser.go` (`matches` case + builder)
- Modify: `internal/filter/render.go` (`renderRegex`)
- Modify: `internal/filter/schema.go` (add `cel.ValidateRegexLiterals()` to both schemas)
- Modify: `internal/filter/engine_test.go`, `store/test/memo_filter_test.go`
- [ ] **Step 1: Add failing tests** — compile-level in `internal/filter/engine_test.go`:
```go
func TestCompileRejectsMalformedRegex(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
_, err = engine.Compile(context.Background(), `content.matches("(")`)
require.Error(t, err)
}
func TestCompileMatchesRendersRegexOperator(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
stmt, err := engine.CompileToStatement(context.Background(), `content.matches("v[0-9]+")`, RenderOptions{Dialect: DialectPostgres})
require.NoError(t, err)
require.Contains(t, stmt.SQL, "~")
require.Equal(t, []any{"v[0-9]+"}, stmt.Args)
}
```
And behavioral in `store/test/memo_filter_test.go` (runs against SQLite by default, exercising the registered `REGEXP` function):
```go
func TestMemoFilterContentMatches(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
tc.CreateMemo(NewMemoBuilder("memo-v1", tc.User.ID).Content("release v12 shipped"))
tc.CreateMemo(NewMemoBuilder("memo-plain", tc.User.ID).Content("no version here"))
memos := tc.ListWithFilter(`content.matches("v[0-9]+")`)
require.Len(t, memos, 1)
require.Equal(t, "memo-v1", memos[0].UID)
memos = tc.ListWithFilter(`content.matches("^xyz")`)
require.Len(t, memos, 0)
}
```
- [ ] **Step 2: Run to verify they fail**
Run: `go test ./internal/filter/ -run 'Malformed|MatchesRenders' -v && go test ./store/test/ -run TestMemoFilterContentMatches -v`
Expected: FAIL — `matches` is unhandled and no regex validator is configured.
- [ ] **Step 3: Add the IR node** in `internal/filter/ir.go`
```go
// RegexCondition models field.matches("pattern") on a string field.
type RegexCondition struct {
Field string
Pattern string
}
func (*RegexCondition) isCondition() {}
```
- [ ] **Step 4: Add parser support** in `internal/filter/parser.go`
In `buildCallCondition`, after the `case "endsWith":` block, add:
```go
case "matches":
return buildMatchesCondition(call, schema)
```
Then add the builder:
```go
func buildMatchesCondition(call *exprv1.Expr_Call, schema Schema) (Condition, error) {
if call.Target == nil {
return nil, errors.New("matches requires a target")
}
targetName, err := getIdentName(call.Target)
if err != nil {
return nil, err
}
field, ok := schema.Field(targetName)
if !ok {
return nil, errors.Errorf("unknown identifier %q", targetName)
}
if !field.SupportsContains {
return nil, errors.Errorf("identifier %q does not support matches()", targetName)
}
if len(call.Args) != 1 {
return nil, errors.New("matches expects exactly one argument")
}
value, err := getConstValue(call.Args[0])
if err != nil {
return nil, errors.Wrap(err, "matches only supports literal arguments")
}
pattern, ok := value.(string)
if !ok {
return nil, errors.New("matches argument must be a string")
}
return &RegexCondition{Field: targetName, Pattern: pattern}, nil
}
```
- [ ] **Step 5: Add the renderer** in `internal/filter/render.go`
In `renderCondition`, after the `case *TextMatchCondition:` arm, add:
```go
case *RegexCondition:
return r.renderRegex(c)
```
Then add:
```go
func (r *renderer) renderRegex(cond *RegexCondition) (renderResult, error) {
field, ok := r.schema.Field(cond.Field)
if !ok {
return renderResult{}, errors.Errorf("unknown field %q", cond.Field)
}
column := field.columnExpr(r.dialect)
switch r.dialect {
case DialectPostgres:
// POSIX regex match operator.
return renderResult{sql: fmt.Sprintf("%s ~ %s", column, r.addArg(cond.Pattern))}, nil
case DialectMySQL, DialectSQLite:
// MySQL has a native REGEXP operator; SQLite uses the registered regexp() function.
return renderResult{sql: fmt.Sprintf("%s REGEXP %s", column, r.addArg(cond.Pattern))}, nil
default:
return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect)
}
}
```
- [ ] **Step 6: Add the regex validator** in `internal/filter/schema.go`
Add the `cel` import line already present. In **both** `NewSchema` and `NewAttachmentSchema`, append the validator to the `envOptions` slice (e.g. after `nowFunction`):
```go
cel.ASTValidators(cel.ValidateRegexLiterals()),
```
- [ ] **Step 7: Run tests to verify green**
Run: `go test ./internal/filter/ -run 'Malformed|MatchesRenders' -v && go test ./store/test/ -run TestMemoFilterContentMatches -v`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add internal/filter/ir.go internal/filter/parser.go internal/filter/render.go internal/filter/schema.go internal/filter/engine_test.go store/test/memo_filter_test.go
git commit -m "feat(filter): support matches() regex on string fields
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: `all()` comprehension on tags (non-empty)
The heaviest task. `exists()` matches against the *serialized* JSON array and cannot express "every element matches", so `all()` needs real per-element iteration via `json_each` / `jsonb_array_elements_text` / `JSON_TABLE`, plus a non-empty guard.
**Files:**
- Modify: `internal/filter/ir.go` (add `ComprehensionAll`)
- Modify: `internal/filter/parser.go` (accept `all()` in `detectComprehensionKind`)
- Modify: `internal/filter/render.go` (`renderTagAll` + element predicate SQL; branch in `renderListComprehension`)
- Modify: `store/test/memo_filter_test.go`
- [ ] **Step 1: Add failing behavioral tests** in `store/test/memo_filter_test.go`
```go
func TestMemoFilterTagsAll(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
tc.CreateMemo(NewMemoBuilder("memo-all-work", tc.User.ID).Content("all work").Tags("work/a", "work/b"))
tc.CreateMemo(NewMemoBuilder("memo-mixed", tc.User.ID).Content("mixed").Tags("work/a", "home"))
tc.CreateMemo(NewMemoBuilder("memo-untagged", tc.User.ID).Content("untagged"))
// Every tag starts with "work/": only the all-work memo qualifies.
memos := tc.ListWithFilter(`tags.all(t, t.startsWith("work/"))`)
require.Len(t, memos, 1)
require.Equal(t, "memo-all-work", memos[0].UID)
// Untagged memos must NOT match (non-empty guard, decision B).
require.NotContains(t, uids(memos), "memo-untagged")
}
func TestMemoFilterTagsAllEquals(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
tc.CreateMemo(NewMemoBuilder("memo-only-x", tc.User.ID).Content("only x").Tags("x", "x"))
tc.CreateMemo(NewMemoBuilder("memo-x-and-y", tc.User.ID).Content("x and y").Tags("x", "y"))
memos := tc.ListWithFilter(`tags.all(t, t == "x")`)
require.Len(t, memos, 1)
require.Equal(t, "memo-only-x", memos[0].UID)
}
```
Add this helper near the top of `store/test/memo_filter_test.go` (after the imports) if not already present:
```go
func uids(memos []*store.Memo) []string {
out := make([]string, 0, len(memos))
for _, m := range memos {
out = append(out, m.UID)
}
return out
}
```
- [ ] **Step 2: Run to verify they fail**
Run: `go test ./store/test/ -run 'TestMemoFilterTagsAll' -v`
Expected: FAIL — `detectComprehensionKind` returns "all() comprehension is not supported".
- [ ] **Step 3: Add the IR kind** in `internal/filter/ir.go`
In the `ComprehensionKind` const block, add `ComprehensionAll`:
```go
const (
ComprehensionExists ComprehensionKind = "exists"
ComprehensionAll ComprehensionKind = "all"
)
```
- [ ] **Step 4: Accept `all()` in the parser** in `internal/filter/parser.go`
In `detectComprehensionKind`, replace the `all()` rejection block:
```go
// all() starts with true and uses AND (&&) - not supported
if accuInit.GetBoolValue() {
if step := comp.LoopStep.GetCallExpr(); step != nil && step.Function == "_&&_" {
return "", errors.New("all() comprehension is not supported; use exists() instead")
}
}
```
with:
```go
// all() starts with true and uses AND (&&) in the loop step.
if accuInit.GetBoolValue() {
if step := comp.LoopStep.GetCallExpr(); step != nil && step.Function == "_&&_" {
return ComprehensionAll, nil
}
}
```
- [ ] **Step 5: Branch and render in `internal/filter/render.go`**
At the top of `renderListComprehension`, right after the `field.Kind != FieldKindJSONList` guard, add:
```go
if cond.Kind == ComprehensionAll {
return r.renderTagAll(field, cond.Predicate)
}
```
Then add the new render path + element-predicate helper:
```go
// renderTagAll renders tags.all(t, <pred>): the array is non-empty AND no element
// fails the predicate. Element predicates use plain CEL semantics (case-insensitive
// for startsWith/endsWith/contains, case-sensitive for ==), evaluated per element.
func (r *renderer) renderTagAll(field Field, pred PredicateExpr) (renderResult, error) {
arrayExpr := jsonArrayExpr(r.dialect, field)
elemCond, err := r.elementPredicateSQL(pred)
if err != nil {
return renderResult{}, err
}
switch r.dialect {
case DialectSQLite:
nonEmpty := fmt.Sprintf("%s IS NOT NULL AND %s != '[]'", arrayExpr, arrayExpr)
sub := fmt.Sprintf("NOT EXISTS (SELECT 1 FROM json_each(%s) WHERE NOT (%s))", arrayExpr, elemCond)
return renderResult{sql: fmt.Sprintf("(%s AND %s)", nonEmpty, sub)}, nil
case DialectMySQL:
nonEmpty := fmt.Sprintf("%s IS NOT NULL AND JSON_LENGTH(%s) > 0", arrayExpr, arrayExpr)
sub := fmt.Sprintf("NOT EXISTS (SELECT 1 FROM JSON_TABLE(%s, '$[*]' COLUMNS (value VARCHAR(512) PATH '$')) AS elem WHERE NOT (%s))", arrayExpr, elemCond)
return renderResult{sql: fmt.Sprintf("(%s AND %s)", nonEmpty, sub)}, nil
case DialectPostgres:
nonEmpty := fmt.Sprintf("%s IS NOT NULL AND jsonb_array_length(%s) > 0", arrayExpr, arrayExpr)
sub := fmt.Sprintf("NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(%s) AS elem(value) WHERE NOT (%s))", arrayExpr, elemCond)
return renderResult{sql: fmt.Sprintf("(%s AND %s)", nonEmpty, sub)}, nil
default:
return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect)
}
}
// elementPredicateSQL builds the per-element SQL condition for an all() predicate.
// The iterated element is exposed as the unqualified column `value` on all dialects
// (json_each.value / JSON_TABLE column / elem(value)).
func (r *renderer) elementPredicateSQL(pred PredicateExpr) (string, error) {
switch p := pred.(type) {
case *EqualsPredicate:
return fmt.Sprintf("value = %s", r.addArg(p.Value)), nil
case *StartsWithPredicate:
return r.foldedLike("value", likePattern(TextMatchPrefix, p.Prefix)), nil
case *EndsWithPredicate:
return r.foldedLike("value", likePattern(TextMatchSuffix, p.Suffix)), nil
case *ContainsPredicate:
return r.foldedLike("value", likePattern(TextMatchContains, p.Substring)), nil
default:
return "", errors.Errorf("unsupported predicate %T in all()", pred)
}
}
```
> Note: `foldedLike`, `likePattern`, and `escapeLikeLiteral` were added in Task 1; reuse them as-is.
- [ ] **Step 6: Run tests to verify green**
Run: `go test ./store/test/ -run 'TestMemoFilterTagsAll' -v`
Expected: PASS.
Run: `go test ./store/test/ -run 'TestMemoFilterTagsExists' -v`
Expected: PASS (exists() rendering untouched).
- [ ] **Step 7: Commit**
```bash
git add internal/filter/ir.go internal/filter/parser.go internal/filter/render.go store/test/memo_filter_test.go
git commit -m "feat(filter): support all() comprehension over tags
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 6: Docs + full verification
**Files:**
- Modify: `internal/filter/README.md`
- [ ] **Step 1: Document the new syntax** — append to the "SQL Generation Notes" section of `internal/filter/README.md`:
```markdown
- **String Matching** — `content.contains(x)`, `content.startsWith(x)`, and
`content.endsWith(x)` render as case-insensitive `LIKE`/`ILIKE` with LIKE
metacharacters (`%`, `_`, `\`) escaped. Available on scalar string fields whose
schema sets `SupportsContains` (memo `content`; attachment `filename`,
`mime_type`).
- **Regex** — `field.matches("pattern")` renders to `~` (Postgres) or `REGEXP`
(MySQL/SQLite). SQLite uses a Go-backed `regexp` function registered in
`store/db/sqlite/functions.go`. Patterns are validated at compile time against
Go's RE2 via `cel.ValidateRegexLiterals()`. **Caveat:** regex *syntax* differs
per engine (Go RE2 on SQLite, POSIX ERE on Postgres, ICU on MySQL 8.0+), so
engine-specific patterns may not be portable.
- **Tag `all()`** — `tags.all(t, <pred>)` matches only non-empty tag sets where
every element satisfies the predicate, via per-element iteration
(`json_each` / `jsonb_array_elements_text` / `JSON_TABLE`).
```
- [ ] **Step 2: Run the full engine + store suite (SQLite)**
Run: `go test ./internal/filter/... ./store/...`
Expected: PASS.
- [ ] **Step 3: Vet and lint**
Run: `go vet ./internal/filter/... ./store/db/sqlite/...`
Expected: no output.
Run: `golangci-lint run internal/filter/... store/db/sqlite/...` (if available; skip if the binary is absent).
Expected: no findings.
- [ ] **Step 4: Cross-dialect verification (if Docker/CI DSNs available)**
Run MySQL and Postgres suites to confirm the `all()` subqueries and regex operators render correctly:
```bash
DRIVER=mysql go test ./store/test/ -run 'TagsAll|Matches|StartsWith|EndsWith'
DRIVER=postgres go test ./store/test/ -run 'TagsAll|Matches|StartsWith|EndsWith'
```
Expected: PASS. (These require the project's standard test DB setup; if unavailable locally, rely on CI which runs all three drivers.)
- [ ] **Step 5: Commit**
```bash
git add internal/filter/README.md
git commit -m "docs(filter): document string matching, regex, and tag all() support
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Self-review notes
- **Spec coverage:** ① scalar `startsWith`/`endsWith` → Task 2; ② `all()` non-empty → Task 5; ④ `matches()` + SQLite REGEXP fn + `ValidateRegexLiterals` → Tasks 3-4; the LIKE-escaping fix → Task 1; docs/caveat → Task 6. `lowerAscii`/`upperAscii` correctly omitted (dropped in spec). Hardening/native-AST migration correctly deferred to the follow-up spec.
- **Type consistency:** `TextMatchCondition`/`TextMatchMode`/`likePattern`/`foldedLike`/`escapeLikeLiteral` (Task 1) are reused by Tasks 2 and 5; `RegexCondition`/`renderRegex` (Task 4) and `ensureRegexpRegistered`/`compileRegexp` (Task 3) names match across their call sites; `ComprehensionAll` (Task 5) matches its parser and render references.
- **Element reference:** the unqualified `value` column is produced by `json_each` (SQLite), the `JSON_TABLE(... COLUMNS (value ...))` (MySQL), and `elem(value)` (Postgres), so `elementPredicateSQL` is dialect-agnostic.
```
@@ -0,0 +1,257 @@
# Design: Expand the CEL filter surface
- **Date:** 2026-06-15
- **Status:** Approved (design); ready for implementation planning
- **Area:** `internal/filter` (memo & attachment filter engine)
- **Follow-up spec:** CEL engine hardening + native-AST migration (separate, sequenced after this)
## Summary
memos lets API clients pass a CEL expression in the `filter` field of list
requests. The `internal/filter` engine uses `cel-go` purely as a **parse +
type-check frontend**, then walks the AST and translates it into a SQL `WHERE`
fragment for the active dialect (SQLite / MySQL / Postgres). cel-go never
evaluates anything.
This spec adds three new CEL constructs that users can write, each with a SQL
translation across all three dialects:
1. `startsWith()` / `endsWith()` on scalar string fields (case-insensitive).
2. `all()` comprehension on tag lists (matches only non-empty tag sets).
3. `matches(regex)` on string fields.
## Goals
- Expose the three constructs above through the existing
parse → IR → render pipeline.
- Keep parity across SQLite, MySQL, and Postgres, with golden tests for each.
- Preserve the engine's invariant: only schema-declared fields and explicitly
supported operations are accepted; everything else is rejected with a clear
error.
## Non-goals
- **Value-producing CEL features with no SQL form** are explicitly out of scope:
optional types (`?.`, `optional.of`), `map()` / `filter()` transforms, the
math extension, string-manipulation extensions (`replace`, `split`,
`substring`, `format`), and two-variable comprehensions. There is nothing to
push into a `WHERE` clause for these.
- **`lowerAscii()` / `upperAscii()`** — dropped. `contains()` is already
case-insensitive on all dialects, and the new `startsWith`/`endsWith` are
case-insensitive too (see decisions), so explicit case-folding adds little.
Revisit only if users ask.
- **Parser hardening and the native-AST proto migration** are a separate
follow-up spec. The one exception that rides along here is
`cel.ValidateRegexLiterals()`, which feature ③ requires for safety.
## Background: how the engine works today
Pipeline (see `internal/filter/README.md`):
1. **Parse**`env.Compile(filter)` parses and type-checks against the
memo/attachment environment declared in `schema.go`; the AST is converted via
`cel.AstToParsedExpr()`.
2. **Normalize**`parser.go` walks the CEL `Expr` and builds a
dialect-agnostic IR (`ir.go`): logical ops, comparisons, `IN`, `contains()`,
and `exists()` comprehensions over tag lists.
3. **Render**`render.go` walks the IR and emits dialect-specific SQL plus
placeholder args.
Two existing facts that shaped this design:
- **`contains()` is already case-insensitive** on all three dialects
(`render.go` `renderContainsCondition`): SQLite uses the custom
`memos_unicode_lower` function, Postgres uses `ILIKE`, MySQL relies on its
default case-insensitive collation.
- **Custom SQLite scalar functions are already registered**
(`store/db/sqlite/functions.go`, `ensureUnicodeLowerRegistered` via
`modernc.org/sqlite`'s `RegisterScalarFunction`, invoked from
`store/db/sqlite/sqlite.go`). The new `REGEXP` function follows this exact
pattern.
cel-go version: `v0.28.0` (latest is `v0.28.1`, a patch with nothing relevant to
memos). No version bump is required for this work.
## Resolved decisions
| # | Decision | Choice |
|---|----------|--------|
| A | Case-sensitivity of new scalar `startsWith`/`endsWith` | **Case-insensitive**, consistent with existing `contains()`. `==` stays case-sensitive (exact match). |
| B | `all()` over a memo with zero tags | **Require non-empty**: an untagged memo does NOT match an `all()` filter. (Diverges from strict CEL vacuous-truth, but matches search-box intuition.) |
| C | Keep `lowerAscii()` / `upperAscii()`? | **Drop** from this spec. |
## Detailed design
### ① `startsWith()` / `endsWith()` on scalar string fields
**Surface.** Allow `field.startsWith("x")` and `field.endsWith("x")` as
top-level boolean calls for scalar string fields. Today these functions are only
recognized *inside* tag comprehensions (`parser.go` `extractPredicate`).
Applicable fields: memo `content`; attachment `filename`, `mime_type`.
`creator` is intentionally **excluded**: it is an identity field with `==`/`!=`
semantics whose column is wrapped as `'users/' || username`, so prefix/suffix
matching there would match against the `users/` prefix and surprise users.
**Schema.** Generalize the per-field text-matching capability. Today `Field` has
`SupportsContains bool`. Replace/extend with a capability that also covers
prefix/suffix matching (e.g. a `SupportsTextMatch bool`, or reuse
`SupportsContains` to gate all three LIKE-based ops). Fields that already set
`SupportsContains: true` gain prefix/suffix support.
**Parser.** In `buildCallCondition`, recognize `startsWith` / `endsWith` calls
whose target is a scalar string field and whose single argument is a string
literal. Reject non-literal arguments and fields without the capability.
**IR.** Generalize `ContainsCondition` into a single node:
```go
type TextMatchMode string
const (
TextMatchContains TextMatchMode = "contains"
TextMatchPrefix TextMatchMode = "prefix"
TextMatchSuffix TextMatchMode = "suffix"
)
type TextMatchCondition struct {
Field string
Mode TextMatchMode
Value string
}
```
`contains()` migrates to `TextMatchCondition{Mode: TextMatchContains}`.
**Render.** Build a `LIKE` pattern from the (escaped) literal:
- prefix → `value%`
- suffix → `%value`
- contains → `%value%`
Reuse the existing case-insensitive rendering already used by `contains()`:
SQLite `memos_unicode_lower(col) LIKE memos_unicode_lower(?)`, Postgres
`col ILIKE $n`, MySQL `col LIKE ?`.
**LIKE-escaping fix.** The current `contains()` renderer interpolates the raw
value into the pattern without escaping `%`, `_`, or `\`. This means a search
for `50%` behaves as a wildcard. The new shared path will escape these
metacharacters (and emit `ESCAPE '\'` where required by the dialect). This
closes a small latent wildcard-injection inconsistency and applies uniformly to
contains/prefix/suffix.
### ② `all()` comprehension on tag lists
**Surface.** Allow `tags.all(t, <pred>)` where `<pred>` is one of the predicates
already supported for `exists()`: `t == "x"`, `t.startsWith("x")`,
`t.endsWith("x")`, `t.contains("x")`.
**Parser.** `detectComprehensionKind` currently accepts only `exists()` and
explicitly rejects `all()`. Add a `ComprehensionAll` kind (accumulator inits to
`true`, loop step uses `_&&_`). Reuse the existing predicate extraction.
**IR.** Add `ComprehensionAll` to the `ComprehensionKind` enum; the existing
`ListComprehensionCondition` already carries `Kind`.
**Render — proper per-element semantics.** The existing `exists()`
implementation matches the *serialized* JSON array text with `LIKE`, which works
for "at least one element matches a substring" but **cannot** express "every
element matches." `all()` therefore needs real per-element iteration. Decision B
(require non-empty) means: array is non-empty **AND** no element fails the
predicate.
- **SQLite:**
```sql
(<array> IS NOT NULL AND <array> != '[]'
AND NOT EXISTS (SELECT 1 FROM json_each(<array>)
WHERE NOT (<predicate on json_each.value>)))
```
- **Postgres:**
```sql
(<array> IS NOT NULL AND jsonb_array_length(<array>) > 0
AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(<array>) AS e(value)
WHERE NOT (<predicate on e.value>)))
```
- **MySQL:**
```sql
(<array> IS NOT NULL AND JSON_LENGTH(<array>) > 0
AND NOT EXISTS (SELECT 1 FROM JSON_TABLE(<array>, '$[*]'
COLUMNS (value VARCHAR(512) PATH '$')) AS j
WHERE NOT (<predicate on j.value>)))
```
The per-element predicate reuses LIKE/`=` against the element `value`
(case-insensitive for `startsWith`/`endsWith`/`contains`, consistent with ①).
Hierarchical-tag prefix behavior should match the existing `exists()` rendering
(a prefix matches the exact tag or a `tag/...` child).
> Note: this introduces correlated subqueries against the same `memo.payload`
> column the outer query already reads; confirm the generated SQL composes with
> the surrounding `WHERE` and placeholder offsets in `helpers.AppendConditions`.
### ④ `matches(regex)` on string fields
**Surface.** Allow `field.matches("pattern")` for the same free-text fields as ①
(`content`, `filename`, `mime_type`; `creator` excluded), literal pattern only.
**Env / validation.** Add `cel.ValidateRegexLiterals()` to the env options in
`schema.go` so malformed patterns fail at compile time with a clear message
(validated against Go's RE2).
**Parser / IR.** Recognize `matches` calls; add:
```go
type RegexCondition struct {
Field string
Pattern string
}
```
Reject non-literal patterns and fields without text-match capability.
**Render.**
- **Postgres:** `col ~ $n`
- **MySQL:** `col REGEXP ?`
- **SQLite:** `col REGEXP ?`. SQLite desugars `X REGEXP Y` to the function call
`regexp(Y, X)`, so register a 2-arg scalar function named `regexp(pattern,
value)` returning 1/0, backed by Go's `regexp` package, following the
`ensureUnicodeLowerRegistered` pattern in `store/db/sqlite/functions.go`.
Compile patterns lazily with a small cache (or rely on RE2 compile per call;
decide during implementation based on measured cost).
**Documented caveats** (engine differences are inherent, not bugs):
- Regex *syntax* differs per engine: SQLite uses Go RE2; Postgres uses POSIX
ERE; MySQL 8.0+ uses ICU. Portable patterns work everywhere; engine-specific
constructs may not. Document this in `internal/filter/README.md`.
- ReDoS risk is low: RE2 (SQLite path) is linear-time; Postgres/MySQL POSIX
engines do not catastrophically backtrack. `ValidateRegexLiterals()` rejects
patterns that don't compile under RE2 as a first-line guard.
## Testing strategy
For each feature, add golden tests in
`store/db/{sqlite,mysql,postgres}/memo_filter_test.go` (and the attachment
filter tests where applicable):
- **Happy path:** assert the exact SQL fragment and args per dialect.
- **Error paths:** non-literal argument, unsupported field, malformed regex,
unsupported predicate inside `all()`.
- **`all()` empty-set:** confirm an untagged memo does not match (decision B).
- **LIKE escaping:** confirm `%`, `_`, `\` in `contains`/`startsWith`/`endsWith`
values are treated literally.
Run `go test ./...` (engine unit tests plus all three dialect suites). The
`contains()``TextMatchCondition` refactor must keep existing golden outputs
unchanged except for the intentional escaping fix.
## Rollout / sequencing
This is the first of two specs. The second (already agreed) covers engine
hardening: tightened parser limits (`ParserExpressionSizeLimit`,
`ParserRecursionLimit`, `ParserErrorRecoveryLimit`), the
`ValidateComprehensionNestingLimit` / `ValidateHomogeneousAggregateLiterals`
validators, and migrating `parser.go` off the deprecated
`genproto/.../expr/v1alpha1` proto to the native `common/ast` API. Building this
surface-expansion spec first is acceptable; the hardening migration is a pure
refactor that the golden tests written here will help protect.