Add child_counters migration with ON DELETE CASCADE (bd-bb08)
Amp-Thread-ID: https://ampcode.com/threads/T-9edaf5ed-11e2-49fe-93f2-2224ecd143f6 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
163
internal/storage/sqlite/child_counters_migration_test.go
Normal file
163
internal/storage/sqlite/child_counters_migration_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func TestMigrateChildCountersTable(t *testing.T) {
|
||||
t.Run("creates child_counters table if not exists", func(t *testing.T) {
|
||||
// Create a temp database without child_counters table
|
||||
dbFile, err := os.CreateTemp("", "beads_test_*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp db: %v", err)
|
||||
}
|
||||
defer os.Remove(dbFile.Name())
|
||||
dbFile.Close()
|
||||
|
||||
db, err := sql.Open("sqlite3", dbFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Create minimal schema without child_counters
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE issues (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open'
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create issues table: %v", err)
|
||||
}
|
||||
|
||||
// Verify child_counters doesn't exist
|
||||
var count int
|
||||
err = db.QueryRow(`
|
||||
SELECT COUNT(*) FROM sqlite_master
|
||||
WHERE type='table' AND name='child_counters'
|
||||
`).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check for child_counters table: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("child_counters table should not exist yet, got count %d", count)
|
||||
}
|
||||
|
||||
// Run migration
|
||||
err = migrateChildCountersTable(db)
|
||||
if err != nil {
|
||||
t.Fatalf("migration failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify child_counters exists
|
||||
err = db.QueryRow(`
|
||||
SELECT COUNT(*) FROM sqlite_master
|
||||
WHERE type='table' AND name='child_counters'
|
||||
`).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check for child_counters table: %v", err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatalf("child_counters table not created, got count %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("is idempotent", func(t *testing.T) {
|
||||
// Create storage with full schema (including child_counters)
|
||||
s := newTestStore(t, "")
|
||||
defer s.Close()
|
||||
|
||||
db := s.db
|
||||
|
||||
// Run migration twice
|
||||
err := migrateChildCountersTable(db)
|
||||
if err != nil {
|
||||
t.Fatalf("first migration failed: %v", err)
|
||||
}
|
||||
|
||||
err = migrateChildCountersTable(db)
|
||||
if err != nil {
|
||||
t.Fatalf("second migration failed (not idempotent): %v", err)
|
||||
}
|
||||
|
||||
// Verify table exists
|
||||
var count int
|
||||
err = db.QueryRow(`
|
||||
SELECT COUNT(*) FROM sqlite_master
|
||||
WHERE type='table' AND name='child_counters'
|
||||
`).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check for child_counters table: %v", err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatalf("child_counters table not found, got count %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("has ON DELETE CASCADE constraint", func(t *testing.T) {
|
||||
// Create storage with full schema
|
||||
s := newTestStore(t, "")
|
||||
defer s.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a parent issue
|
||||
parent := &types.Issue{
|
||||
ID: "bd-parent",
|
||||
Title: "Parent",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeEpic,
|
||||
}
|
||||
err := s.CreateIssue(ctx, parent, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create parent issue: %v", err)
|
||||
}
|
||||
|
||||
// Generate child ID (this populates child_counters)
|
||||
childID, err := s.GetNextChildID(ctx, "bd-parent")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get next child ID: %v", err)
|
||||
}
|
||||
if childID != "bd-parent.1" {
|
||||
t.Fatalf("expected bd-parent.1, got %s", childID)
|
||||
}
|
||||
|
||||
// Verify child_counters has entry for parent
|
||||
var lastChild int
|
||||
err = s.db.QueryRow(`
|
||||
SELECT last_child FROM child_counters WHERE parent_id = ?
|
||||
`, "bd-parent").Scan(&lastChild)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query child_counters: %v", err)
|
||||
}
|
||||
if lastChild != 1 {
|
||||
t.Fatalf("expected last_child=1, got %d", lastChild)
|
||||
}
|
||||
|
||||
// Delete the parent issue
|
||||
err = s.DeleteIssue(ctx, "bd-parent")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete parent issue: %v", err)
|
||||
}
|
||||
|
||||
// Verify child_counters entry was CASCADE deleted
|
||||
var count int
|
||||
err = s.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM child_counters WHERE parent_id = ?
|
||||
`, "bd-parent").Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query child_counters: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected child_counters entry to be deleted, found %d entries", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -31,6 +31,7 @@ var migrations = []Migration{
|
||||
{"external_ref_unique", migrateExternalRefUnique},
|
||||
{"source_repo_column", migrateSourceRepoColumn},
|
||||
{"repo_mtimes_table", migrateRepoMtimesTable},
|
||||
{"child_counters_table", migrateChildCountersTable},
|
||||
}
|
||||
|
||||
// MigrationInfo contains metadata about a migration for inspection
|
||||
@@ -68,6 +69,7 @@ func getMigrationDescription(name string) string {
|
||||
"external_ref_unique": "Adds UNIQUE constraint on external_ref column",
|
||||
"source_repo_column": "Adds source_repo column for multi-repo support",
|
||||
"repo_mtimes_table": "Adds repo_mtimes table for multi-repo hydration caching",
|
||||
"child_counters_table": "Adds child_counters table for hierarchical ID generation with ON DELETE CASCADE",
|
||||
}
|
||||
|
||||
if desc, ok := descriptions[name]; ok {
|
||||
@@ -644,3 +646,35 @@ func migrateRepoMtimesTable(db *sql.DB) error {
|
||||
// Table already exists
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateChildCountersTable creates the child_counters table for hierarchical ID generation (bd-bb08)
|
||||
func migrateChildCountersTable(db *sql.DB) error {
|
||||
// Check if child_counters table exists
|
||||
var tableName string
|
||||
err := db.QueryRow(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='child_counters'
|
||||
`).Scan(&tableName)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
// Table doesn't exist, create it
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE child_counters (
|
||||
parent_id TEXT PRIMARY KEY,
|
||||
last_child INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (parent_id) REFERENCES issues(id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create child_counters table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for child_counters table: %w", err)
|
||||
}
|
||||
|
||||
// Table already exists
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user