package migrations
import (
"context"
"database/sql"
"os"
"strings"
_ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose/v3"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const idColumnsMigrationVersion = 20260720015443
var _ = Describe("idColumns inventory", func() {
It("covers every id-bearing column present at the migration version", func() {
ctx := context.Background()
db, err := sql.Open("sqlite3", "file::memory:")
Expect(err).ToNot(HaveOccurred())
db.SetMaxOpenConns(1)
DeferCleanup(func() { _ = db.Close() })
_, err = db.ExecContext(ctx, "PRAGMA foreign_keys=off")
Expect(err).ToNot(HaveOccurred())
goose.SetBaseFS(os.DirFS("."))
DeferCleanup(func() { goose.SetBaseFS(nil) })
Expect(goose.SetDialect("sqlite3")).To(Succeed())
Expect(goose.UpToContext(ctx, db, ".", idColumnsMigrationVersion)).To(Succeed())
covered := map[string]bool{}
for _, tc := range idColumns {
covered[tc.table+"."+tc.col] = true
}
for _, tc := range embeddedIDColumns {
covered[tc.table+"."+tc.col] = true
}
exempt := map[string]string{
"share.id": "public share URLs, generated separately",
"property.id": "property key, not an entity id",
"plugin.id": "plugin name, not an entity id",
"media_file.participants": "hash-family artist ids, unchanged",
"album.participants": "hash-family artist ids, unchanged",
"media_file.tags": "hash-family tag ids, unchanged",
"album.tags": "hash-family tag ids, unchanged",
"album.folder_ids": "hash-family folder ids, unchanged",
"artist.similar_artists": "hash-family artist ids, unchanged",
"media_file.search_participants": "participant names for FTS, not ids",
"album.search_participants": "participant names for FTS, not ids",
"album.discs": "disc number -> title map",
"media_file.lyrics": "synced lyrics, no ids",
"folder.image_files": "image file names, no ids",
"plugin.manifest": "plugin-authored manifest, no Navidrome ids",
"plugin.config": "free-form plugin config, must not be rewritten",
"plugin.libraries": "integer library ids",
}
tables, err := queryColumn(ctx, db, "SELECT name FROM sqlite_master WHERE type='table'")
Expect(err).ToNot(HaveOccurred())
var dangling []string
for _, table := range tables {
if strings.HasPrefix(table, "sqlite_") || table == "goose_db_version" || strings.Contains(table, "_fts") {
continue
}
rows, err := db.QueryContext(ctx, "SELECT name, type FROM pragma_table_info(?)", table)
Expect(err).ToNot(HaveOccurred())
for rows.Next() {
var name, typ string
Expect(rows.Scan(&name, &typ)).To(Succeed())
lname := strings.ToLower(name)
utyp := strings.ToUpper(typ)
isJSON := strings.Contains(utyp, "JSON")
isIDName := lname == "id" || lname == "pid" ||
strings.HasSuffix(lname, "_id") || strings.HasSuffix(lname, "_ids")
if !isJSON && !isIDName {
continue
}
if !isJSON && !strings.Contains(utyp, "TEXT") && !strings.Contains(utyp, "CHAR") {
continue
}
if strings.HasPrefix(lname, "mbz_") {
continue
}
key := table + "." + name
if covered[key] || exempt[key] != "" {
continue
}
dangling = append(dangling, key)
}
Expect(rows.Err()).ToNot(HaveOccurred())
Expect(rows.Close()).To(Succeed())
}
Expect(dangling).To(BeEmpty(),
"id-bearing columns missing from idColumns; add them to the migration or exempt with a reason: %v", dangling)
})
})
func queryColumn(ctx context.Context, db *sql.DB, query string) ([]string, error) {
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}