Skip to content

Commit 93b6df0

Browse files
hmansclaude
andcommitted
feat: support short IDs (without prefix) in GraphQL queries
- Get() and Delete() now accept short IDs (e.g., "abc1" matches "beans-abc1") - Replace fuzzy prefix matching with exact matching only - Remove unused ErrAmbiguousID error - Update tests and schema documentation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 29e3a88 commit 93b6df0

File tree

5 files changed

+137
-92
lines changed

5 files changed

+137
-92
lines changed

internal/beancore/core.go

Lines changed: 30 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,7 @@ import (
1919

2020
const BeansDir = ".beans"
2121

22-
var (
23-
ErrNotFound = errors.New("bean not found")
24-
ErrAmbiguousID = errors.New("ambiguous ID prefix matches multiple beans")
25-
)
22+
var ErrNotFound = errors.New("bean not found")
2623

2724
// Core provides thread-safe in-memory storage for beans with filesystem persistence.
2825
type Core struct {
@@ -257,32 +254,27 @@ func (c *Core) All() []*bean.Bean {
257254
return result
258255
}
259256

260-
// Get finds a bean by ID or ID prefix.
261-
func (c *Core) Get(idPrefix string) (*bean.Bean, error) {
257+
// Get finds a bean by exact ID match.
258+
// If a prefix is configured and the query doesn't include it, the prefix is automatically prepended.
259+
// For example, with prefix "beans-", Get("abc") will match "beans-abc" but Get("ab") will not.
260+
func (c *Core) Get(id string) (*bean.Bean, error) {
262261
c.mu.RLock()
263262
defer c.mu.RUnlock()
264263

265-
// First try exact match
266-
if b, ok := c.beans[idPrefix]; ok {
264+
// Try exact match
265+
if b, ok := c.beans[id]; ok {
267266
return b, nil
268267
}
269268

270-
// Then try prefix match
271-
var matches []*bean.Bean
272-
for id, b := range c.beans {
273-
if strings.HasPrefix(id, idPrefix) {
274-
matches = append(matches, b)
269+
// If not found and we have a configured prefix that isn't already in the query,
270+
// try with the prefix prepended (allows short IDs like "abc" to match "beans-abc")
271+
if c.config != nil && c.config.Beans.Prefix != "" && !strings.HasPrefix(id, c.config.Beans.Prefix) {
272+
if b, ok := c.beans[c.config.Beans.Prefix+id]; ok {
273+
return b, nil
275274
}
276275
}
277276

278-
switch len(matches) {
279-
case 0:
280-
return nil, ErrNotFound
281-
case 1:
282-
return matches[0], nil
283-
default:
284-
return nil, ErrAmbiguousID
285-
}
277+
return nil, ErrNotFound
286278
}
287279

288280
// Create adds a new bean, generating an ID if needed, and writes it to disk.
@@ -389,37 +381,28 @@ func (c *Core) saveToDisk(b *bean.Bean) error {
389381
return nil
390382
}
391383

392-
// Delete removes a bean by ID or ID prefix.
393-
func (c *Core) Delete(idPrefix string) error {
384+
// Delete removes a bean by exact ID match.
385+
// Supports short IDs (without prefix) if a prefix is configured.
386+
func (c *Core) Delete(id string) error {
394387
c.mu.Lock()
395388
defer c.mu.Unlock()
396389

397-
// Find the bean (need to handle prefix matching)
398-
var targetID string
399-
var targetBean *bean.Bean
400-
401-
// First try exact match
402-
if b, ok := c.beans[idPrefix]; ok {
403-
targetID = idPrefix
404-
targetBean = b
405-
} else {
406-
// Try prefix match
407-
var matches []string
408-
for id, b := range c.beans {
409-
if strings.HasPrefix(id, idPrefix) {
410-
matches = append(matches, id)
411-
targetBean = b
412-
}
390+
// Find the bean by exact match
391+
targetID := id
392+
targetBean, ok := c.beans[id]
393+
394+
// If not found and we have a configured prefix, try with prefix prepended
395+
if !ok && c.config != nil && c.config.Beans.Prefix != "" && !strings.HasPrefix(id, c.config.Beans.Prefix) {
396+
fullID := c.config.Beans.Prefix + id
397+
if b, found := c.beans[fullID]; found {
398+
targetID = fullID
399+
targetBean = b
400+
ok = true
413401
}
402+
}
414403

415-
switch len(matches) {
416-
case 0:
417-
return ErrNotFound
418-
case 1:
419-
targetID = matches[0]
420-
default:
421-
return ErrAmbiguousID
422-
}
404+
if !ok {
405+
return ErrNotFound
423406
}
424407

425408
// Remove from disk

internal/beancore/core_test.go

Lines changed: 97 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,6 @@ func TestGet(t *testing.T) {
180180

181181
createTestBean(t, core, "abc1", "First", "todo")
182182
createTestBean(t, core, "def2", "Second", "todo")
183-
createTestBean(t, core, "ghi3", "Third", "todo")
184183

185184
t.Run("exact match", func(t *testing.T) {
186185
b, err := core.Get("abc1")
@@ -192,23 +191,10 @@ func TestGet(t *testing.T) {
192191
}
193192
})
194193

195-
t.Run("prefix match", func(t *testing.T) {
196-
b, err := core.Get("de")
197-
if err != nil {
198-
t.Fatalf("Get() error = %v", err)
199-
}
200-
if b.ID != "def2" {
201-
t.Errorf("ID = %q, want %q", b.ID, "def2")
202-
}
203-
})
204-
205-
t.Run("single char prefix", func(t *testing.T) {
206-
b, err := core.Get("g")
207-
if err != nil {
208-
t.Fatalf("Get() error = %v", err)
209-
}
210-
if b.ID != "ghi3" {
211-
t.Errorf("ID = %q, want %q", b.ID, "ghi3")
194+
t.Run("partial ID not found", func(t *testing.T) {
195+
_, err := core.Get("abc")
196+
if err != ErrNotFound {
197+
t.Errorf("Get() error = %v, want ErrNotFound", err)
212198
}
213199
})
214200
}
@@ -224,16 +210,66 @@ func TestGetNotFound(t *testing.T) {
224210
}
225211
}
226212

227-
func TestGetAmbiguous(t *testing.T) {
228-
core, _ := setupTestCore(t)
229213

230-
createTestBean(t, core, "abc1", "First", "todo")
231-
createTestBean(t, core, "abc2", "Second", "todo")
214+
func TestGetShortID(t *testing.T) {
215+
// Create a core with a configured prefix
216+
tmpDir := t.TempDir()
217+
beansDir := filepath.Join(tmpDir, BeansDir)
218+
if err := os.MkdirAll(beansDir, 0755); err != nil {
219+
t.Fatalf("failed to create test .beans dir: %v", err)
220+
}
232221

233-
_, err := core.Get("abc")
234-
if err != ErrAmbiguousID {
235-
t.Errorf("Get() error = %v, want ErrAmbiguousID", err)
222+
cfg := config.DefaultWithPrefix("beans-")
223+
core := New(beansDir, cfg)
224+
core.SetWarnWriter(nil)
225+
if err := core.Load(); err != nil {
226+
t.Fatalf("failed to load core: %v", err)
236227
}
228+
229+
// Create beans with the prefix
230+
createTestBean(t, core, "beans-abc1", "First", "todo")
231+
createTestBean(t, core, "beans-def2", "Second", "todo")
232+
233+
t.Run("short ID exact match", func(t *testing.T) {
234+
b, err := core.Get("abc1")
235+
if err != nil {
236+
t.Fatalf("Get() error = %v", err)
237+
}
238+
if b.ID != "beans-abc1" {
239+
t.Errorf("ID = %q, want %q", b.ID, "beans-abc1")
240+
}
241+
})
242+
243+
t.Run("full ID exact match", func(t *testing.T) {
244+
b, err := core.Get("beans-abc1")
245+
if err != nil {
246+
t.Fatalf("Get() error = %v", err)
247+
}
248+
if b.ID != "beans-abc1" {
249+
t.Errorf("ID = %q, want %q", b.ID, "beans-abc1")
250+
}
251+
})
252+
253+
t.Run("partial short ID not found", func(t *testing.T) {
254+
_, err := core.Get("abc")
255+
if err != ErrNotFound {
256+
t.Errorf("Get() error = %v, want ErrNotFound", err)
257+
}
258+
})
259+
260+
t.Run("partial full ID not found", func(t *testing.T) {
261+
_, err := core.Get("beans-ab")
262+
if err != ErrNotFound {
263+
t.Errorf("Get() error = %v, want ErrNotFound", err)
264+
}
265+
})
266+
267+
t.Run("nonexistent ID not found", func(t *testing.T) {
268+
_, err := core.Get("xyz")
269+
if err != ErrNotFound {
270+
t.Errorf("Get() error = %v, want ErrNotFound", err)
271+
}
272+
})
237273
}
238274

239275
func TestUpdate(t *testing.T) {
@@ -327,24 +363,54 @@ func TestDeleteNotFound(t *testing.T) {
327363
}
328364
}
329365

330-
func TestDeleteByPrefix(t *testing.T) {
331-
core, _ := setupTestCore(t)
366+
func TestDeleteShortID(t *testing.T) {
367+
// Create a core with a configured prefix
368+
tmpDir := t.TempDir()
369+
beansDir := filepath.Join(tmpDir, BeansDir)
370+
if err := os.MkdirAll(beansDir, 0755); err != nil {
371+
t.Fatalf("failed to create test .beans dir: %v", err)
372+
}
332373

333-
createTestBean(t, core, "unique123", "Test", "todo")
374+
cfg := config.DefaultWithPrefix("beans-")
375+
core := New(beansDir, cfg)
376+
core.SetWarnWriter(nil)
377+
if err := core.Load(); err != nil {
378+
t.Fatalf("failed to load core: %v", err)
379+
}
334380

335-
// Delete by prefix
336-
err := core.Delete("unique")
381+
createTestBean(t, core, "beans-xyz1", "Test", "todo")
382+
383+
// Delete by short ID (without prefix)
384+
err := core.Delete("xyz1")
337385
if err != nil {
338386
t.Fatalf("Delete() error = %v", err)
339387
}
340388

341389
// Verify it's gone
342-
_, err = core.Get("unique123")
390+
_, err = core.Get("beans-xyz1")
343391
if err != ErrNotFound {
344392
t.Error("bean should be deleted")
345393
}
346394
}
347395

396+
func TestDeletePartialIDNotFound(t *testing.T) {
397+
core, _ := setupTestCore(t)
398+
399+
createTestBean(t, core, "unique123", "Test", "todo")
400+
401+
// Partial ID should not match
402+
err := core.Delete("unique")
403+
if err != ErrNotFound {
404+
t.Errorf("Delete() error = %v, want ErrNotFound", err)
405+
}
406+
407+
// Verify bean still exists
408+
_, err = core.Get("unique123")
409+
if err != nil {
410+
t.Errorf("bean should still exist, got error: %v", err)
411+
}
412+
}
413+
348414
func TestFullPath(t *testing.T) {
349415
core := New("/path/to/.beans", nil)
350416

internal/graph/schema.graphqls

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ scalar Time
44

55
type Query {
66
"""
7-
Get a single bean by ID (supports prefix matching)
7+
Get a single bean by ID. Accepts either the full ID (e.g., "beans-abc1") or the short ID without prefix (e.g., "abc1").
88
"""
99
bean(id: ID!): Bean
1010

internal/graph/schema.resolvers_test.go

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,18 +65,15 @@ func TestQueryBean(t *testing.T) {
6565
}
6666
})
6767

68-
// Test prefix match
69-
t.Run("prefix match", func(t *testing.T) {
68+
// Test partial ID not found (no prefix matching)
69+
t.Run("partial ID not found", func(t *testing.T) {
7070
qr := resolver.Query()
7171
got, err := qr.Bean(ctx, "test")
7272
if err != nil {
7373
t.Fatalf("Bean() error = %v", err)
7474
}
75-
if got == nil {
76-
t.Fatal("Bean() returned nil")
77-
}
78-
if got.ID != "test-1" {
79-
t.Errorf("Bean().ID = %q, want %q", got.ID, "test-1")
75+
if got != nil {
76+
t.Errorf("Bean() = %v, want nil (partial IDs should not match)", got)
8077
}
8178
})
8279

internal/output/output.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,11 @@ import (
1010

1111
// Error codes for JSON responses
1212
const (
13-
ErrNotFound = "NOT_FOUND"
14-
ErrAmbiguousID = "AMBIGUOUS_ID"
15-
ErrNoBeansDir = "NO_BEANS_DIR"
16-
ErrInvalidStatus = "INVALID_STATUS"
17-
ErrFileError = "FILE_ERROR"
18-
ErrValidation = "VALIDATION_ERROR"
13+
ErrNotFound = "NOT_FOUND"
14+
ErrNoBeansDir = "NO_BEANS_DIR"
15+
ErrInvalidStatus = "INVALID_STATUS"
16+
ErrFileError = "FILE_ERROR"
17+
ErrValidation = "VALIDATION_ERROR"
1918
)
2019

2120
// Response is the standard JSON response envelope.

0 commit comments

Comments
 (0)