feat(backend): record tag usage in filters to activity.tag_uses
Listing files with a tag filter now logs each referenced tag to activity.tag_uses, flagging it included (positive) or excluded (negated under an odd number of NOTs); the untagged pseudo-token is skipped. The filter AST is reused to determine polarity, so grouped negations like !(A|B) mark both tags excluded. Recording happens only when a filter is first applied — not on cursor pagination or an anchored return — so one browse counts once. The write is best-effort and never fails the listing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -811,3 +811,33 @@ func (r *FileRepo) RecordView(ctx context.Context, fileID uuid.UUID, userID int1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordTagUses appends a row to activity.tag_uses for each tag referenced in a
|
||||
// filter DSL, flagging it included (positive) or excluded (negated). Tags are
|
||||
// deduplicated per call, so one statement_timestamp() never collides on the
|
||||
// (tag_id, used_at, user_id) PK; ON CONFLICT DO NOTHING guards the rest. A
|
||||
// filter with no tag terms is a no-op.
|
||||
func (r *FileRepo) RecordTagUses(ctx context.Context, userID int16, filterDSL string) error {
|
||||
uses := filterTagUses(filterDSL)
|
||||
if len(uses) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("INSERT INTO activity.tag_uses (tag_id, user_id, is_included) VALUES ")
|
||||
args := make([]any, 0, len(uses)*3)
|
||||
for i, u := range uses {
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
base := i * 3
|
||||
fmt.Fprintf(&sb, "($%d, $%d, $%d)", base+1, base+2, base+3)
|
||||
args = append(args, u.tagID, userID, u.included)
|
||||
}
|
||||
sb.WriteString(" ON CONFLICT DO NOTHING")
|
||||
|
||||
if _, err := connOrTx(ctx, r.pool).Exec(ctx, sb.String(), args...); err != nil {
|
||||
return fmt.Errorf("FileRepo.RecordTagUses: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -253,6 +253,31 @@ func (p *filterParser) parseAtom() (filterNode, error) {
|
||||
// Public entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// parseFilterAST lexes and parses a filter DSL into an AST. Returns (nil, nil)
|
||||
// for an empty or trivial DSL.
|
||||
func parseFilterAST(dsl string) (filterNode, error) {
|
||||
dsl = strings.TrimSpace(dsl)
|
||||
if dsl == "" || dsl == "{}" {
|
||||
return nil, nil
|
||||
}
|
||||
toks, err := lexFilter(dsl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(toks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
p := &filterParser{tokens: toks}
|
||||
node, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.pos != len(p.tokens) {
|
||||
return nil, fmt.Errorf("filter: trailing tokens at position %d", p.pos)
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// ParseFilter parses a filter DSL string into a parameterized SQL fragment.
|
||||
//
|
||||
// argStart is the 1-based index for the first $N placeholder; this lets the
|
||||
@@ -262,25 +287,62 @@ func (p *filterParser) parseAtom() (filterNode, error) {
|
||||
// SQL injection is structurally impossible: every user-supplied value is
|
||||
// bound as a query parameter ($N), never interpolated into the SQL string.
|
||||
func ParseFilter(dsl string, argStart int) (sql string, nextN int, args []any, err error) {
|
||||
dsl = strings.TrimSpace(dsl)
|
||||
if dsl == "" || dsl == "{}" {
|
||||
return "", argStart, nil, nil
|
||||
}
|
||||
toks, err := lexFilter(dsl)
|
||||
node, err := parseFilterAST(dsl)
|
||||
if err != nil {
|
||||
return "", argStart, nil, err
|
||||
}
|
||||
if len(toks) == 0 {
|
||||
if node == nil {
|
||||
return "", argStart, nil, nil
|
||||
}
|
||||
p := &filterParser{tokens: toks}
|
||||
node, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return "", argStart, nil, err
|
||||
}
|
||||
if p.pos != len(p.tokens) {
|
||||
return "", argStart, nil, fmt.Errorf("filter: trailing tokens at position %d", p.pos)
|
||||
}
|
||||
sql, nextN, args = node.toSQL(argStart, nil)
|
||||
return sql, nextN, args, nil
|
||||
}
|
||||
|
||||
// tagUse is a tag referenced by a filter, with whether it was included
|
||||
// (positive) or excluded (negated under an odd number of NOTs).
|
||||
type tagUse struct {
|
||||
tagID uuid.UUID
|
||||
included bool
|
||||
}
|
||||
|
||||
// filterTagUses extracts the distinct tag references in a filter DSL, marking
|
||||
// each as included or excluded. The "untagged" pseudo-token (zero UUID) is
|
||||
// skipped. Returns nil for a filter with no tag terms; an unparseable filter
|
||||
// also yields nil (extraction is best-effort analytics, not validation).
|
||||
func filterTagUses(dsl string) []tagUse {
|
||||
node, err := parseFilterAST(dsl)
|
||||
if err != nil || node == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[uuid.UUID]bool)
|
||||
collectTagUses(node, true, seen)
|
||||
if len(seen) == 0 {
|
||||
return nil
|
||||
}
|
||||
uses := make([]tagUse, 0, len(seen))
|
||||
for id, inc := range seen {
|
||||
uses = append(uses, tagUse{tagID: id, included: inc})
|
||||
}
|
||||
return uses
|
||||
}
|
||||
|
||||
// collectTagUses walks the AST, recording each real tag leaf into out keyed by
|
||||
// id. included flips under every NOT, so a tag is "excluded" only when nested
|
||||
// under an odd number of NOTs. A tag appearing under both polarities keeps the
|
||||
// last seen — pathological, but it avoids a duplicate-key insert.
|
||||
func collectTagUses(node filterNode, included bool, out map[uuid.UUID]bool) {
|
||||
switch nd := node.(type) {
|
||||
case *andNode:
|
||||
collectTagUses(nd.left, included, out)
|
||||
collectTagUses(nd.right, included, out)
|
||||
case *orNode:
|
||||
collectTagUses(nd.left, included, out)
|
||||
collectTagUses(nd.right, included, out)
|
||||
case *notNode:
|
||||
collectTagUses(nd.child, !included, out)
|
||||
case *leafNode:
|
||||
if nd.tok.kind == ftkTag && !nd.tok.untagged {
|
||||
out[nd.tok.tagID] = included
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestFilterTagUses(t *testing.T) {
|
||||
a := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
b := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dsl string
|
||||
want map[uuid.UUID]bool // tag → included; absence means "not recorded"
|
||||
}{
|
||||
{"single included", "{t=" + a.String() + "}", map[uuid.UUID]bool{a: true}},
|
||||
{"single excluded", "{!,t=" + a.String() + "}", map[uuid.UUID]bool{a: false}},
|
||||
{"double negation is included", "{!,!,t=" + a.String() + "}", map[uuid.UUID]bool{a: true}},
|
||||
{
|
||||
"and of two included",
|
||||
"{t=" + a.String() + ",&,t=" + b.String() + "}",
|
||||
map[uuid.UUID]bool{a: true, b: true},
|
||||
},
|
||||
{
|
||||
"not over a group excludes both",
|
||||
"{!,(,t=" + a.String() + ",|,t=" + b.String() + ",)}",
|
||||
map[uuid.UUID]bool{a: false, b: false},
|
||||
},
|
||||
{"untagged pseudo-token skipped", "{t=" + uuid.Nil.String() + "}", map[uuid.UUID]bool{}},
|
||||
{"mime-only filter records nothing", "{m=3}", map[uuid.UUID]bool{}},
|
||||
{"empty filter", "{}", map[uuid.UUID]bool{}},
|
||||
{"unparseable filter is best-effort nil", "{t=not-a-uuid}", map[uuid.UUID]bool{}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := make(map[uuid.UUID]bool)
|
||||
for _, u := range filterTagUses(tc.dsl) {
|
||||
got[u.tagID] = u.included
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("got %d uses %v, want %d %v", len(got), got, len(tc.want), tc.want)
|
||||
}
|
||||
for id, inc := range tc.want {
|
||||
if g, ok := got[id]; !ok || g != inc {
|
||||
t.Errorf("tag %s: got (included=%v, present=%v), want included=%v", id, g, ok, inc)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user