Add external_ref UNIQUE constraint and validation
- Add migration for UNIQUE index on external_ref column (bd-897a) - Add validation for duplicate external_ref in batch imports (bd-7315) - Add query planner test to verify index usage (bd-f9a1) - Add concurrent import tests for external_ref (bd-3f6a) The migration detects existing duplicates and fails gracefully. Batch imports now reject duplicates with clear error messages. Tests verify the index is actually used by SQLite query planner. Amp-Thread-ID: https://ampcode.com/threads/T-45ca66ed-3912-46c4-963c-caa7724a9a2f Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -4,6 +4,7 @@ package sqlite
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
@@ -27,6 +28,7 @@ var migrations = []Migration{
|
||||
{"compacted_at_commit_column", migrateCompactedAtCommitColumn},
|
||||
{"export_hashes_table", migrateExportHashesTable},
|
||||
{"content_hash_column", migrateContentHashColumn},
|
||||
{"external_ref_unique", migrateExternalRefUnique},
|
||||
}
|
||||
|
||||
// MigrationInfo contains metadata about a migration for inspection
|
||||
@@ -61,6 +63,7 @@ func getMigrationDescription(name string) string {
|
||||
"compacted_at_commit_column": "Adds compacted_at_commit to snapshots table",
|
||||
"export_hashes_table": "Adds export_hashes table for idempotent exports",
|
||||
"content_hash_column": "Adds content_hash column for collision resolution",
|
||||
"external_ref_unique": "Adds UNIQUE constraint on external_ref column",
|
||||
}
|
||||
|
||||
if desc, ok := descriptions[name]; ok {
|
||||
@@ -510,3 +513,62 @@ func migrateContentHashColumn(db *sql.DB) error {
|
||||
// Column already exists
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateExternalRefUnique(db *sql.DB) error {
|
||||
var hasConstraint bool
|
||||
err := db.QueryRow(`
|
||||
SELECT COUNT(*) > 0
|
||||
FROM sqlite_master
|
||||
WHERE type = 'index'
|
||||
AND name = 'idx_issues_external_ref_unique'
|
||||
`).Scan(&hasConstraint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for UNIQUE constraint: %w", err)
|
||||
}
|
||||
|
||||
if hasConstraint {
|
||||
return nil
|
||||
}
|
||||
|
||||
existingDuplicates, err := findExternalRefDuplicates(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for duplicate external_ref values: %w", err)
|
||||
}
|
||||
|
||||
if len(existingDuplicates) > 0 {
|
||||
return fmt.Errorf("cannot add UNIQUE constraint: found %d duplicate external_ref values (resolve with 'bd duplicates' or manually)", len(existingDuplicates))
|
||||
}
|
||||
|
||||
_, err = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_issues_external_ref_unique ON issues(external_ref) WHERE external_ref IS NOT NULL`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create UNIQUE index on external_ref: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func findExternalRefDuplicates(db *sql.DB) (map[string][]string, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT external_ref, GROUP_CONCAT(id, ',') as ids
|
||||
FROM issues
|
||||
WHERE external_ref IS NOT NULL
|
||||
GROUP BY external_ref
|
||||
HAVING COUNT(*) > 1
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
duplicates := make(map[string][]string)
|
||||
for rows.Next() {
|
||||
var externalRef, idsCSV string
|
||||
if err := rows.Scan(&externalRef, &idsCSV); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := strings.Split(idsCSV, ",")
|
||||
duplicates[externalRef] = ids
|
||||
}
|
||||
|
||||
return duplicates, rows.Err()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user