Fix N+1 query pattern in export operations (bd-rcmg)
**Problem**: Export operations called GetLabels() and GetIssueComments() in a loop for each issue, creating N+1 query pattern. For 100 issues this created 201 queries instead of 3-5. **Solution**: - Added GetCommentsForIssues() batch method to storage interface - Implemented batch method in SQLite and memory storage backends - Updated handleExport() and triggerExport() to use batch queries - Added comprehensive tests for batch operations **Impact**: Query count reduced from ~201 to ~3-5 for 100 issues. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
143
internal/storage/sqlite/batch_test.go
Normal file
143
internal/storage/sqlite/batch_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// TestBatchGetLabelsAndComments verifies that GetLabelsForIssues and GetCommentsForIssues
|
||||
// correctly fetch data for multiple issues in a single query (avoiding N+1 pattern)
|
||||
func TestBatchGetLabelsAndComments(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create test issues
|
||||
issues := []*types.Issue{
|
||||
{
|
||||
ID: "bd-batch1",
|
||||
Title: "Issue 1",
|
||||
IssueType: types.TypeTask,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
},
|
||||
{
|
||||
ID: "bd-batch2",
|
||||
Title: "Issue 2",
|
||||
IssueType: types.TypeTask,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
},
|
||||
{
|
||||
ID: "bd-batch3",
|
||||
Title: "Issue 3",
|
||||
IssueType: types.TypeTask,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, issue := range issues {
|
||||
if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil {
|
||||
t.Fatalf("Failed to create issue %s: %v", issue.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add labels to issues
|
||||
if err := store.AddLabel(ctx, "bd-batch1", "bug", "test-actor"); err != nil {
|
||||
t.Fatalf("Failed to add label: %v", err)
|
||||
}
|
||||
if err := store.AddLabel(ctx, "bd-batch1", "urgent", "test-actor"); err != nil {
|
||||
t.Fatalf("Failed to add label: %v", err)
|
||||
}
|
||||
if err := store.AddLabel(ctx, "bd-batch2", "feature", "test-actor"); err != nil {
|
||||
t.Fatalf("Failed to add label: %v", err)
|
||||
}
|
||||
// bd-batch3 has no labels
|
||||
|
||||
// Add comments to issues
|
||||
if _, err := store.AddIssueComment(ctx, "bd-batch1", "alice", "First comment"); err != nil {
|
||||
t.Fatalf("Failed to add comment: %v", err)
|
||||
}
|
||||
if _, err := store.AddIssueComment(ctx, "bd-batch1", "bob", "Second comment"); err != nil {
|
||||
t.Fatalf("Failed to add comment: %v", err)
|
||||
}
|
||||
if _, err := store.AddIssueComment(ctx, "bd-batch3", "charlie", "Comment on bd-batch3"); err != nil {
|
||||
t.Fatalf("Failed to add comment: %v", err)
|
||||
}
|
||||
// bd-batch2 has no comments
|
||||
|
||||
// Test batch get labels
|
||||
issueIDs := []string{"bd-batch1", "bd-batch2", "bd-batch3"}
|
||||
allLabels, err := store.GetLabelsForIssues(ctx, issueIDs)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLabelsForIssues failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify labels
|
||||
if len(allLabels["bd-batch1"]) != 2 {
|
||||
t.Errorf("Expected 2 labels for bd-batch1, got %d", len(allLabels["bd-batch1"]))
|
||||
}
|
||||
if len(allLabels["bd-batch2"]) != 1 {
|
||||
t.Errorf("Expected 1 label for bd-batch2, got %d", len(allLabels["bd-batch2"]))
|
||||
}
|
||||
if len(allLabels["bd-batch3"]) != 0 {
|
||||
t.Errorf("Expected 0 labels for bd-batch3, got %d", len(allLabels["bd-batch3"]))
|
||||
}
|
||||
|
||||
// Test batch get comments
|
||||
allComments, err := store.GetCommentsForIssues(ctx, issueIDs)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCommentsForIssues failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify comments
|
||||
if len(allComments["bd-batch1"]) != 2 {
|
||||
t.Errorf("Expected 2 comments for bd-batch1, got %d", len(allComments["bd-batch1"]))
|
||||
}
|
||||
if allComments["bd-batch1"][0].Author != "alice" {
|
||||
t.Errorf("Expected first comment author to be 'alice', got %s", allComments["bd-batch1"][0].Author)
|
||||
}
|
||||
if allComments["bd-batch1"][1].Author != "bob" {
|
||||
t.Errorf("Expected second comment author to be 'bob', got %s", allComments["bd-batch1"][1].Author)
|
||||
}
|
||||
|
||||
if len(allComments["bd-batch2"]) != 0 {
|
||||
t.Errorf("Expected 0 comments for bd-batch2, got %d", len(allComments["bd-batch2"]))
|
||||
}
|
||||
|
||||
if len(allComments["bd-batch3"]) != 1 {
|
||||
t.Errorf("Expected 1 comment for bd-batch3, got %d", len(allComments["bd-batch3"]))
|
||||
}
|
||||
if len(allComments["bd-batch3"]) > 0 && allComments["bd-batch3"][0].Author != "charlie" {
|
||||
t.Errorf("Expected comment author to be 'charlie', got %s", allComments["bd-batch3"][0].Author)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchGetEmptyIssueIDs verifies that batch methods handle empty issue ID lists
|
||||
func TestBatchGetEmptyIssueIDs(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with empty issue ID list
|
||||
labels, err := store.GetLabelsForIssues(ctx, []string{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetLabelsForIssues with empty list failed: %v", err)
|
||||
}
|
||||
if len(labels) != 0 {
|
||||
t.Errorf("Expected empty map, got %d entries", len(labels))
|
||||
}
|
||||
|
||||
comments, err := store.GetCommentsForIssues(ctx, []string{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetCommentsForIssues with empty list failed: %v", err)
|
||||
}
|
||||
if len(comments) != 0 {
|
||||
t.Errorf("Expected empty map, got %d entries", len(comments))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user