Add dependency and dependent counts to bd list JSON output (#198)

When using `bd list --json`, each issue now includes:
- `dependency_count`: Number of issues this issue depends on
- `dependent_count`: Number of issues that depend on this issue

This provides quick access to dependency relationship counts without
needing to fetch full dependency lists or run multiple bd show commands.

Performance:
- Uses single bulk query (GetDependencyCounts) instead of N individual queries
- Overhead: ~26% for 500 issues (24ms vs 19ms baseline)
- Avoids N+1 query problem that would have caused 2.2x slowdown

Implementation:
- Added GetDependencyCounts() to Storage interface for bulk counting
- Efficient SQLite query using UNION ALL + GROUP BY
- Memory storage implementation for testing
- Moved IssueWithCounts to types package to avoid duplication
- Both RPC and direct modes use optimized bulk query

Tests:
- Added comprehensive tests for GetDependencyCounts
- Tests cover: normal operation, empty list, nonexistent IDs
- All existing tests continue to pass

Backwards compatible: JSON structure is additive, all original fields preserved.

Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Nikolai Prokoschenko
2025-11-02 03:59:15 +01:00
committed by GitHub
parent 21ab565819
commit c65cfa1ebd
8 changed files with 300 additions and 16 deletions

View File

@@ -555,6 +555,46 @@ func (m *MemoryStorage) GetDependents(ctx context.Context, issueID string) ([]*t
return results, nil
}
// GetDependencyCounts returns dependency and dependent counts for multiple issues
func (m *MemoryStorage) GetDependencyCounts(ctx context.Context, issueIDs []string) (map[string]*types.DependencyCounts, error) {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string]*types.DependencyCounts)
// Initialize all requested IDs with zero counts
for _, id := range issueIDs {
result[id] = &types.DependencyCounts{
DependencyCount: 0,
DependentCount: 0,
}
}
// Build a set for quick lookup
idSet := make(map[string]bool)
for _, id := range issueIDs {
idSet[id] = true
}
// Count dependencies (issues that this issue depends on)
for _, id := range issueIDs {
if deps, exists := m.dependencies[id]; exists {
result[id].DependencyCount = len(deps)
}
}
// Count dependents (issues that depend on this issue)
for _, deps := range m.dependencies {
for _, dep := range deps {
if idSet[dep.DependsOnID] {
result[dep.DependsOnID].DependentCount++
}
}
}
return result, nil
}
// GetDependencyRecords gets dependency records for an issue
func (m *MemoryStorage) GetDependencyRecords(ctx context.Context, issueID string) ([]*types.Dependency, error) {
m.mu.RLock()

View File

@@ -237,6 +237,80 @@ func (s *SQLiteStorage) GetDependents(ctx context.Context, issueID string) ([]*t
return s.scanIssues(ctx, rows)
}
// GetDependencyCounts returns dependency and dependent counts for multiple issues in a single query
func (s *SQLiteStorage) GetDependencyCounts(ctx context.Context, issueIDs []string) (map[string]*types.DependencyCounts, error) {
if len(issueIDs) == 0 {
return make(map[string]*types.DependencyCounts), nil
}
// Build placeholders for the IN clause
placeholders := make([]string, len(issueIDs))
args := make([]interface{}, len(issueIDs)*2)
for i, id := range issueIDs {
placeholders[i] = "?"
args[i] = id
args[len(issueIDs)+i] = id
}
inClause := strings.Join(placeholders, ",")
// Single query that counts both dependencies and dependents
// Uses UNION ALL to combine results from both directions
query := fmt.Sprintf(`
SELECT
issue_id,
SUM(CASE WHEN type = 'dependency' THEN count ELSE 0 END) as dependency_count,
SUM(CASE WHEN type = 'dependent' THEN count ELSE 0 END) as dependent_count
FROM (
-- Count dependencies (issues this issue depends on)
SELECT issue_id, 'dependency' as type, COUNT(*) as count
FROM dependencies
WHERE issue_id IN (%s)
GROUP BY issue_id
UNION ALL
-- Count dependents (issues that depend on this issue)
SELECT depends_on_id as issue_id, 'dependent' as type, COUNT(*) as count
FROM dependencies
WHERE depends_on_id IN (%s)
GROUP BY depends_on_id
)
GROUP BY issue_id
`, inClause, inClause)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to get dependency counts: %w", err)
}
defer func() { _ = rows.Close() }()
result := make(map[string]*types.DependencyCounts)
for rows.Next() {
var issueID string
var counts types.DependencyCounts
if err := rows.Scan(&issueID, &counts.DependencyCount, &counts.DependentCount); err != nil {
return nil, fmt.Errorf("failed to scan dependency counts: %w", err)
}
result[issueID] = &counts
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating dependency counts: %w", err)
}
// Fill in zero counts for issues with no dependencies or dependents
for _, id := range issueIDs {
if _, exists := result[id]; !exists {
result[id] = &types.DependencyCounts{
DependencyCount: 0,
DependentCount: 0,
}
}
}
return result, nil
}
// GetDependencyRecords returns raw dependency records for an issue
func (s *SQLiteStorage) GetDependencyRecords(ctx context.Context, issueID string) ([]*types.Dependency, error) {
rows, err := s.db.QueryContext(ctx, `

View File

@@ -903,3 +903,108 @@ func TestGetDependencyTree_SubstringBug(t *testing.T) {
t.Errorf("Expected bd-1 at depth 4, got %d", depthMap[issues[0].ID])
}
}
func TestGetDependencyCounts(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create a network of issues with dependencies
// A (depends on B, C)
// B (depends on C)
// C (no dependencies)
// D (depends on A)
// E (no dependencies, no dependents)
issueA := &types.Issue{Title: "Task A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issueB := &types.Issue{Title: "Task B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issueC := &types.Issue{Title: "Task C", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issueD := &types.Issue{Title: "Task D", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issueE := &types.Issue{Title: "Task E", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, issueA, "test-user")
store.CreateIssue(ctx, issueB, "test-user")
store.CreateIssue(ctx, issueC, "test-user")
store.CreateIssue(ctx, issueD, "test-user")
store.CreateIssue(ctx, issueE, "test-user")
// Add dependencies
store.AddDependency(ctx, &types.Dependency{IssueID: issueA.ID, DependsOnID: issueB.ID, Type: types.DepBlocks}, "test-user")
store.AddDependency(ctx, &types.Dependency{IssueID: issueA.ID, DependsOnID: issueC.ID, Type: types.DepBlocks}, "test-user")
store.AddDependency(ctx, &types.Dependency{IssueID: issueB.ID, DependsOnID: issueC.ID, Type: types.DepBlocks}, "test-user")
store.AddDependency(ctx, &types.Dependency{IssueID: issueD.ID, DependsOnID: issueA.ID, Type: types.DepBlocks}, "test-user")
// Get counts for all issues
issueIDs := []string{issueA.ID, issueB.ID, issueC.ID, issueD.ID, issueE.ID}
counts, err := store.GetDependencyCounts(ctx, issueIDs)
if err != nil {
t.Fatalf("GetDependencyCounts failed: %v", err)
}
// Verify counts
testCases := []struct {
issueID string
name string
expectedDeps int
expectedDepents int
}{
{issueA.ID, "A", 2, 1}, // depends on B and C, D depends on A
{issueB.ID, "B", 1, 1}, // depends on C, A depends on B
{issueC.ID, "C", 0, 2}, // no dependencies, A and B depend on C
{issueD.ID, "D", 1, 0}, // depends on A, nothing depends on D
{issueE.ID, "E", 0, 0}, // isolated issue
}
for _, tc := range testCases {
count := counts[tc.issueID]
if count == nil {
t.Errorf("Issue %s (%s): no counts returned", tc.name, tc.issueID)
continue
}
if count.DependencyCount != tc.expectedDeps {
t.Errorf("Issue %s (%s): expected %d dependencies, got %d",
tc.name, tc.issueID, tc.expectedDeps, count.DependencyCount)
}
if count.DependentCount != tc.expectedDepents {
t.Errorf("Issue %s (%s): expected %d dependents, got %d",
tc.name, tc.issueID, tc.expectedDepents, count.DependentCount)
}
}
}
func TestGetDependencyCountsEmpty(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Test with empty list
counts, err := store.GetDependencyCounts(ctx, []string{})
if err != nil {
t.Fatalf("GetDependencyCounts failed on empty list: %v", err)
}
if len(counts) != 0 {
t.Errorf("Expected empty map for empty input, got %d entries", len(counts))
}
}
func TestGetDependencyCountsNonexistent(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Test with non-existent issue IDs
counts, err := store.GetDependencyCounts(ctx, []string{"fake-1", "fake-2"})
if err != nil {
t.Fatalf("GetDependencyCounts failed on nonexistent IDs: %v", err)
}
// Should return zero counts for non-existent issues
for id, count := range counts {
if count.DependencyCount != 0 || count.DependentCount != 0 {
t.Errorf("Expected zero counts for nonexistent issue %s, got deps=%d, dependents=%d",
id, count.DependencyCount, count.DependentCount)
}
}
}

View File

@@ -25,6 +25,7 @@ type Storage interface {
GetDependents(ctx context.Context, issueID string) ([]*types.Issue, error)
GetDependencyRecords(ctx context.Context, issueID string) ([]*types.Dependency, error)
GetAllDependencyRecords(ctx context.Context) (map[string][]*types.Dependency, error)
GetDependencyCounts(ctx context.Context, issueIDs []string) (map[string]*types.DependencyCounts, error)
GetDependencyTree(ctx context.Context, issueID string, maxDepth int, showAllPaths bool, reverse bool) ([]*types.TreeNode, error)
DetectCycles(ctx context.Context) ([][]*types.Issue, error)