Skip to content

Commit e7019f6

Browse files
committed
feat: add filter parameter to GraphQL relationship fields
- Add optional `filter: BeanFilter` param to `children`, `blocking`, and `blockedBy` fields - Extract reusable `ApplyFilter()` function in filters.go - Refactor `Beans` query resolver to use shared filter logic - Update TUI detail view to pass nil filter to resolver calls - Add comprehensive tests for filtered relationship queries - Update prompt template with examples of filtered relationship queries Closes beans-mbi9
1 parent 9e32a11 commit e7019f6

File tree

8 files changed

+369
-101
lines changed

8 files changed

+369
-101
lines changed

.beans/beans-mbi9--graphql-add-filter-parameter-to-relationship-field.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
---
22
title: 'GraphQL: add filter parameter to relationship fields'
3-
status: todo
3+
status: completed
44
type: feature
55
priority: normal
66
created_at: 2025-12-13T00:49:39Z
7-
updated_at: 2025-12-13T02:02:11Z
7+
updated_at: 2025-12-13T10:14:42Z
88
---
99

1010
Add optional filter parameter to relationship fields (children, blocking, blockedBy) to allow filtering related beans directly in nested queries.

cmd/prompt.tmpl

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ Beans can have relationships to other beans. Use these to express dependencies a
8686
# Get a bean with its relationships
8787
beans query '{ bean(id: "abc") { title parent { id title } children { id title status } blockedBy { title } blocking { title } } }'
8888
89+
# Filter children by status (e.g., only incomplete children of a milestone)
90+
beans query '{ bean(id: "abc") { title children(filter: { excludeStatus: ["completed", "scrapped"] }) { id title status } } }'
91+
92+
# Find active blockers (exclude completed ones)
93+
beans query '{ bean(id: "abc") { blockedBy(filter: { excludeStatus: ["completed"] }) { id title } } }'
94+
8995
# Find beans blocked by something
9096
beans query '{ beans(filter: { isBlocked: true }) { id title blockedBy { title } } }'
9197

internal/graph/filters.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,82 @@ package graph
33
import (
44
"github.com/hmans/beans/internal/bean"
55
"github.com/hmans/beans/internal/beancore"
6+
"github.com/hmans/beans/internal/graph/model"
67
)
78

9+
// ApplyFilter applies BeanFilter to a slice of beans and returns filtered results.
10+
// This is used by both the top-level beans query and relationship field resolvers.
11+
func ApplyFilter(beans []*bean.Bean, filter *model.BeanFilter, core *beancore.Core) []*bean.Bean {
12+
if filter == nil {
13+
return beans
14+
}
15+
16+
result := beans
17+
18+
// Status filters
19+
if len(filter.Status) > 0 {
20+
result = filterByField(result, filter.Status, func(b *bean.Bean) string { return b.Status })
21+
}
22+
if len(filter.ExcludeStatus) > 0 {
23+
result = excludeByField(result, filter.ExcludeStatus, func(b *bean.Bean) string { return b.Status })
24+
}
25+
26+
// Type filters
27+
if len(filter.Type) > 0 {
28+
result = filterByField(result, filter.Type, func(b *bean.Bean) string { return b.Type })
29+
}
30+
if len(filter.ExcludeType) > 0 {
31+
result = excludeByField(result, filter.ExcludeType, func(b *bean.Bean) string { return b.Type })
32+
}
33+
34+
// Priority filters (empty priority treated as "normal")
35+
if len(filter.Priority) > 0 {
36+
result = filterByPriority(result, filter.Priority)
37+
}
38+
if len(filter.ExcludePriority) > 0 {
39+
result = excludeByPriority(result, filter.ExcludePriority)
40+
}
41+
42+
// Tag filters
43+
if len(filter.Tags) > 0 {
44+
result = filterByTags(result, filter.Tags)
45+
}
46+
if len(filter.ExcludeTags) > 0 {
47+
result = excludeByTags(result, filter.ExcludeTags)
48+
}
49+
50+
// Parent filters
51+
if filter.HasParent != nil && *filter.HasParent {
52+
result = filterByHasParent(result)
53+
}
54+
if filter.NoParent != nil && *filter.NoParent {
55+
result = filterByNoParent(result)
56+
}
57+
if filter.ParentID != nil && *filter.ParentID != "" {
58+
result = filterByParentID(result, *filter.ParentID)
59+
}
60+
61+
// Blocking filters
62+
if filter.HasBlocking != nil && *filter.HasBlocking {
63+
result = filterByHasBlocking(result)
64+
}
65+
if filter.BlockingID != nil && *filter.BlockingID != "" {
66+
result = filterByBlockingID(result, *filter.BlockingID)
67+
}
68+
if filter.NoBlocking != nil && *filter.NoBlocking {
69+
result = filterByNoBlocking(result)
70+
}
71+
if filter.IsBlocked != nil {
72+
if *filter.IsBlocked {
73+
result = filterByIsBlocked(result, core)
74+
} else {
75+
result = filterByNotBlocked(result, core)
76+
}
77+
}
78+
79+
return result
80+
}
81+
882
// filterByField filters beans to include only those where getter returns a value in values (OR logic).
983
func filterByField(beans []*bean.Bean, values []string, getter func(*bean.Bean) string) []*bean.Bean {
1084
valueSet := make(map[string]bool, len(values))

internal/graph/generated.go

Lines changed: 99 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/graph/schema.graphqls

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,13 @@ type Bean {
121121

122122
# Computed relationship fields
123123
"Beans that block this one (incoming blocking links)"
124-
blockedBy: [Bean!]!
124+
blockedBy(filter: BeanFilter): [Bean!]!
125125
"Beans this one is blocking (resolved from blockingIds)"
126-
blocking: [Bean!]!
126+
blocking(filter: BeanFilter): [Bean!]!
127127
"Parent bean (resolved from parentId)"
128128
parent: Bean
129129
"Child beans (beans with this as parent)"
130-
children: [Bean!]!
130+
children(filter: BeanFilter): [Bean!]!
131131
}
132132

133133
"""

0 commit comments

Comments
 (0)