Skip to content

Commit 080a6b9

Browse files
committed
refactor: extract shared filter-building into buildIssueFilterClauses (bd-is9q)
SearchIssues and searchWisps duplicated ~480 lines of identical filter-building logic targeting different tables. Extract into buildIssueFilterClauses() with a filterTables config for table name differences (issues/wisps, labels/wisp_labels, dependencies/wisp_dependencies). This also upgrades searchWisps to use safer subquery patterns for IssueType/ExcludeTypes filters and adds metadata filter support that was previously missing from the wisps path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Executed-By: beads/polecats/agate Rig: beads Role: polecats
1 parent 60ded4c commit 080a6b9

3 files changed

Lines changed: 284 additions & 476 deletions

File tree

internal/storage/dolt/filters.go

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
package dolt
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"strings"
7+
"time"
8+
9+
"github.com/steveyegge/beads/internal/storage"
10+
"github.com/steveyegge/beads/internal/types"
11+
)
12+
13+
// filterTables configures table names for buildIssueFilterClauses,
14+
// allowing the same filter logic to target both issues and wisps tables.
15+
type filterTables struct {
16+
main string // "issues" or "wisps"
17+
labels string // "labels" or "wisp_labels"
18+
dependencies string // "dependencies" or "wisp_dependencies"
19+
}
20+
21+
var (
22+
issuesFilterTables = filterTables{main: "issues", labels: "labels", dependencies: "dependencies"}
23+
wispsFilterTables = filterTables{main: "wisps", labels: "wisp_labels", dependencies: "wisp_dependencies"}
24+
)
25+
26+
// buildIssueFilterClauses builds WHERE clause fragments and args from a query
27+
// string and IssueFilter. The tables parameter controls which table names are
28+
// referenced in subqueries (issues vs wisps).
29+
func buildIssueFilterClauses(query string, filter types.IssueFilter, tables filterTables) ([]string, []interface{}, error) {
30+
var whereClauses []string
31+
var args []interface{}
32+
33+
// Free-text search
34+
if query != "" {
35+
whereClauses = append(whereClauses, "(title LIKE ? OR description LIKE ? OR id LIKE ?)")
36+
pattern := "%" + query + "%"
37+
args = append(args, pattern, pattern, pattern)
38+
}
39+
40+
if filter.TitleSearch != "" {
41+
whereClauses = append(whereClauses, "title LIKE ?")
42+
args = append(args, "%"+filter.TitleSearch+"%")
43+
}
44+
if filter.TitleContains != "" {
45+
whereClauses = append(whereClauses, "title LIKE ?")
46+
args = append(args, "%"+filter.TitleContains+"%")
47+
}
48+
if filter.DescriptionContains != "" {
49+
whereClauses = append(whereClauses, "description LIKE ?")
50+
args = append(args, "%"+filter.DescriptionContains+"%")
51+
}
52+
if filter.NotesContains != "" {
53+
whereClauses = append(whereClauses, "notes LIKE ?")
54+
args = append(args, "%"+filter.NotesContains+"%")
55+
}
56+
57+
// Status filters
58+
if filter.Status != nil {
59+
whereClauses = append(whereClauses, "status = ?")
60+
args = append(args, *filter.Status)
61+
}
62+
if len(filter.ExcludeStatus) > 0 {
63+
placeholders := make([]string, len(filter.ExcludeStatus))
64+
for i, s := range filter.ExcludeStatus {
65+
placeholders[i] = "?"
66+
args = append(args, string(s))
67+
}
68+
whereClauses = append(whereClauses, fmt.Sprintf("status NOT IN (%s)", strings.Join(placeholders, ",")))
69+
}
70+
71+
// Use subquery for type filter to prevent Dolt mergeJoinIter panic.
72+
// When issue_type equality is combined with other indexed predicates (status, priority)
73+
// in the same WHERE clause, Dolt's query optimizer may select a merge join plan
74+
// between index scans that panics in mergeJoinIter. Isolating the type predicate
75+
// in a subquery forces sequential evaluation and avoids the problematic plan.
76+
if filter.IssueType != nil {
77+
whereClauses = append(whereClauses, fmt.Sprintf("id IN (SELECT id FROM %s WHERE issue_type = ?)", tables.main))
78+
args = append(args, *filter.IssueType)
79+
}
80+
// Use subquery for type exclusion to prevent Dolt mergeJoinIter panic (same as above).
81+
if len(filter.ExcludeTypes) > 0 {
82+
placeholders := make([]string, len(filter.ExcludeTypes))
83+
for i, t := range filter.ExcludeTypes {
84+
placeholders[i] = "?"
85+
args = append(args, string(t))
86+
}
87+
whereClauses = append(whereClauses, fmt.Sprintf("id IN (SELECT id FROM %s WHERE issue_type NOT IN (%s))", tables.main, strings.Join(placeholders, ",")))
88+
}
89+
90+
// Assignee
91+
if filter.Assignee != nil {
92+
whereClauses = append(whereClauses, "assignee = ?")
93+
args = append(args, *filter.Assignee)
94+
}
95+
96+
// Priority filters
97+
if filter.Priority != nil {
98+
whereClauses = append(whereClauses, "priority = ?")
99+
args = append(args, *filter.Priority)
100+
}
101+
if filter.PriorityMin != nil {
102+
whereClauses = append(whereClauses, "priority >= ?")
103+
args = append(args, *filter.PriorityMin)
104+
}
105+
if filter.PriorityMax != nil {
106+
whereClauses = append(whereClauses, "priority <= ?")
107+
args = append(args, *filter.PriorityMax)
108+
}
109+
110+
// ID filters
111+
if len(filter.IDs) > 0 {
112+
placeholders := make([]string, len(filter.IDs))
113+
for i, id := range filter.IDs {
114+
placeholders[i] = "?"
115+
args = append(args, id)
116+
}
117+
whereClauses = append(whereClauses, fmt.Sprintf("id IN (%s)", strings.Join(placeholders, ", ")))
118+
}
119+
if filter.IDPrefix != "" {
120+
whereClauses = append(whereClauses, "id LIKE ?")
121+
args = append(args, filter.IDPrefix+"%")
122+
}
123+
if filter.SpecIDPrefix != "" {
124+
whereClauses = append(whereClauses, "spec_id LIKE ?")
125+
args = append(args, filter.SpecIDPrefix+"%")
126+
}
127+
128+
// Parent/child dependency filters
129+
if filter.ParentID != nil {
130+
parentID := *filter.ParentID
131+
whereClauses = append(whereClauses, fmt.Sprintf("(id IN (SELECT issue_id FROM %s WHERE type = 'parent-child' AND depends_on_id = ?) OR (id LIKE CONCAT(?, '.%%') AND id NOT IN (SELECT issue_id FROM %s WHERE type = 'parent-child')))", tables.dependencies, tables.dependencies))
132+
args = append(args, parentID, parentID)
133+
}
134+
if filter.NoParent {
135+
whereClauses = append(whereClauses, fmt.Sprintf("id NOT IN (SELECT issue_id FROM %s WHERE type = 'parent-child')", tables.dependencies))
136+
}
137+
138+
// Type classification filters
139+
if filter.MolType != nil {
140+
whereClauses = append(whereClauses, "mol_type = ?")
141+
args = append(args, string(*filter.MolType))
142+
}
143+
if filter.WispType != nil {
144+
whereClauses = append(whereClauses, "wisp_type = ?")
145+
args = append(args, string(*filter.WispType))
146+
}
147+
148+
// Label filtering (AND — all labels must be present)
149+
if len(filter.Labels) > 0 {
150+
for _, label := range filter.Labels {
151+
whereClauses = append(whereClauses, fmt.Sprintf("id IN (SELECT issue_id FROM %s WHERE label = ?)", tables.labels))
152+
args = append(args, label)
153+
}
154+
}
155+
// Label filtering (OR — any label matches)
156+
if len(filter.LabelsAny) > 0 {
157+
placeholders := make([]string, len(filter.LabelsAny))
158+
for i, label := range filter.LabelsAny {
159+
placeholders[i] = "?"
160+
args = append(args, label)
161+
}
162+
whereClauses = append(whereClauses, fmt.Sprintf("id IN (SELECT issue_id FROM %s WHERE label IN (%s))", tables.labels, strings.Join(placeholders, ", ")))
163+
}
164+
if filter.NoLabels {
165+
whereClauses = append(whereClauses, fmt.Sprintf("id NOT IN (SELECT DISTINCT issue_id FROM %s)", tables.labels))
166+
}
167+
168+
// Boolean/flag filters
169+
if filter.Pinned != nil {
170+
if *filter.Pinned {
171+
whereClauses = append(whereClauses, "pinned = 1")
172+
} else {
173+
whereClauses = append(whereClauses, "(pinned = 0 OR pinned IS NULL)")
174+
}
175+
}
176+
if filter.SourceRepo != nil {
177+
whereClauses = append(whereClauses, "source_repo = ?")
178+
args = append(args, *filter.SourceRepo)
179+
}
180+
if filter.Ephemeral != nil {
181+
if *filter.Ephemeral {
182+
whereClauses = append(whereClauses, "ephemeral = 1")
183+
} else {
184+
whereClauses = append(whereClauses, "(ephemeral = 0 OR ephemeral IS NULL)")
185+
}
186+
}
187+
if filter.IsTemplate != nil {
188+
if *filter.IsTemplate {
189+
whereClauses = append(whereClauses, "is_template = 1")
190+
} else {
191+
whereClauses = append(whereClauses, "(is_template = 0 OR is_template IS NULL)")
192+
}
193+
}
194+
195+
// Empty/null checks
196+
if filter.EmptyDescription {
197+
whereClauses = append(whereClauses, "(description IS NULL OR description = '')")
198+
}
199+
if filter.NoAssignee {
200+
whereClauses = append(whereClauses, "(assignee IS NULL OR assignee = '')")
201+
}
202+
203+
// Date range filters
204+
if filter.CreatedAfter != nil {
205+
whereClauses = append(whereClauses, "created_at > ?")
206+
args = append(args, filter.CreatedAfter.Format(time.RFC3339))
207+
}
208+
if filter.CreatedBefore != nil {
209+
whereClauses = append(whereClauses, "created_at < ?")
210+
args = append(args, filter.CreatedBefore.Format(time.RFC3339))
211+
}
212+
if filter.UpdatedAfter != nil {
213+
whereClauses = append(whereClauses, "updated_at > ?")
214+
args = append(args, filter.UpdatedAfter.Format(time.RFC3339))
215+
}
216+
if filter.UpdatedBefore != nil {
217+
whereClauses = append(whereClauses, "updated_at < ?")
218+
args = append(args, filter.UpdatedBefore.Format(time.RFC3339))
219+
}
220+
if filter.ClosedAfter != nil {
221+
whereClauses = append(whereClauses, "closed_at > ?")
222+
args = append(args, filter.ClosedAfter.Format(time.RFC3339))
223+
}
224+
if filter.ClosedBefore != nil {
225+
whereClauses = append(whereClauses, "closed_at < ?")
226+
args = append(args, filter.ClosedBefore.Format(time.RFC3339))
227+
}
228+
if filter.DeferAfter != nil {
229+
whereClauses = append(whereClauses, "defer_until > ?")
230+
args = append(args, filter.DeferAfter.Format(time.RFC3339))
231+
}
232+
if filter.DeferBefore != nil {
233+
whereClauses = append(whereClauses, "defer_until < ?")
234+
args = append(args, filter.DeferBefore.Format(time.RFC3339))
235+
}
236+
if filter.DueAfter != nil {
237+
whereClauses = append(whereClauses, "due_at > ?")
238+
args = append(args, filter.DueAfter.Format(time.RFC3339))
239+
}
240+
if filter.DueBefore != nil {
241+
whereClauses = append(whereClauses, "due_at < ?")
242+
args = append(args, filter.DueBefore.Format(time.RFC3339))
243+
}
244+
245+
// Time-based scheduling filters
246+
if filter.Deferred {
247+
whereClauses = append(whereClauses, "defer_until IS NOT NULL")
248+
}
249+
if filter.Overdue {
250+
whereClauses = append(whereClauses, "due_at IS NOT NULL AND due_at < ? AND status != ?")
251+
args = append(args, time.Now().UTC().Format(time.RFC3339), types.StatusClosed)
252+
}
253+
254+
// Metadata filters
255+
if filter.HasMetadataKey != "" {
256+
if err := storage.ValidateMetadataKey(filter.HasMetadataKey); err != nil {
257+
return nil, nil, err
258+
}
259+
whereClauses = append(whereClauses, "JSON_EXTRACT(metadata, ?) IS NOT NULL")
260+
args = append(args, "$."+filter.HasMetadataKey)
261+
}
262+
if len(filter.MetadataFields) > 0 {
263+
metaKeys := make([]string, 0, len(filter.MetadataFields))
264+
for k := range filter.MetadataFields {
265+
metaKeys = append(metaKeys, k)
266+
}
267+
sort.Strings(metaKeys)
268+
for _, k := range metaKeys {
269+
if err := storage.ValidateMetadataKey(k); err != nil {
270+
return nil, nil, err
271+
}
272+
whereClauses = append(whereClauses, "JSON_UNQUOTE(JSON_EXTRACT(metadata, ?)) = ?")
273+
args = append(args, "$."+k, filter.MetadataFields[k])
274+
}
275+
}
276+
277+
return whereClauses, args, nil
278+
}

0 commit comments

Comments
 (0)