🏡 index : quine-global/quintodrome.git

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"
)

// Version of the uniform-canonical-ids migration whose idColumns list this guard protects.
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) // non-shared :memory: — a second conn would be an empty DB
		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
		}
		// Columns that are id-named or JSON but need no rewrite.
		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)
				// JSON can hide an id under any key, so every JSON column needs a verdict.
				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 // INTEGER ids (rowid PKs, library.id) are not canonical ids
				}
				if strings.HasPrefix(lname, "mbz_") {
					continue // MusicBrainz UUIDs, not Navidrome ids
				}
				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()
}