mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-02 16:37:49 +01:00
168 lines
6.4 KiB
Go
168 lines
6.4 KiB
Go
|
|
package persistence
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"database/sql"
|
||
|
|
"fmt"
|
||
|
|
"maps"
|
||
|
|
"regexp"
|
||
|
|
"slices"
|
||
|
|
|
||
|
|
"github.com/navidrome/navidrome/conf"
|
||
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
||
|
|
"github.com/navidrome/navidrome/db"
|
||
|
|
"github.com/navidrome/navidrome/log"
|
||
|
|
"github.com/navidrome/navidrome/model"
|
||
|
|
"github.com/navidrome/navidrome/model/request"
|
||
|
|
. "github.com/onsi/ginkgo/v2"
|
||
|
|
. "github.com/onsi/gomega"
|
||
|
|
)
|
||
|
|
|
||
|
|
// These tests guard against sort options silently losing index support: adding or
|
||
|
|
// changing a sort mapping, or dropping/renaming an index in a migration, must not
|
||
|
|
// reintroduce full-table temp B-tree sorts on the large tables. Those are
|
||
|
|
// catastrophic on big libraries but invisible on dev-sized ones, which is how the
|
||
|
|
// unindexed album/artist song sorts went unnoticed for years.
|
||
|
|
//
|
||
|
|
// Every sort mapping is checked automatically: the real ORDER BY is built via
|
||
|
|
// buildSortOrder (both directions) and verified with EXPLAIN QUERY PLAN against
|
||
|
|
// the migrated test schema. The planner's choice is deterministic even on an
|
||
|
|
// empty table. A sort passes when the plan has no full "USE TEMP B-TREE FOR
|
||
|
|
// ORDER BY" step; an incremental sort of tie groups ("... FOR LAST TERM OF ORDER
|
||
|
|
// BY") is fine, as it only sorts rows with equal leading columns.
|
||
|
|
//
|
||
|
|
// A new sort mapping therefore fails this test until a matching index is created.
|
||
|
|
// The only escape hatch is exceptions, for sorts that genuinely cannot be
|
||
|
|
// served by a table index (random, annotation-join columns, JSON expressions):
|
||
|
|
// declaring one requires writing down the reason, making the trade-off visible in
|
||
|
|
// review. The checks run with the default config: PreferSortTags=true rewrites
|
||
|
|
// mappings to coalesce expressions with no matching indexes (used by ~0.1% of
|
||
|
|
// installations, per insights), and is out of scope here.
|
||
|
|
var _ = Describe("Sort index coverage", func() {
|
||
|
|
conn := db.Db()
|
||
|
|
|
||
|
|
type repoCase struct {
|
||
|
|
table string
|
||
|
|
newRepo func(ctx context.Context) *sqlRepository
|
||
|
|
// sort mapping -> reason it cannot be served by an index
|
||
|
|
exceptions map[string]string
|
||
|
|
}
|
||
|
|
|
||
|
|
cases := []repoCase{
|
||
|
|
{
|
||
|
|
table: "media_file",
|
||
|
|
newRepo: func(ctx context.Context) *sqlRepository {
|
||
|
|
return &NewMediaFileRepository(ctx, GetDBXBuilder()).(*mediaFileRepository).sqlRepository
|
||
|
|
},
|
||
|
|
exceptions: map[string]string{
|
||
|
|
"random": "not a column sort",
|
||
|
|
"starred_at": "sorts on annotation join columns",
|
||
|
|
"rated_at": "sorts on annotation join columns",
|
||
|
|
"play_count": "sorts on annotation join columns",
|
||
|
|
"play_date": "sorts on annotation join columns",
|
||
|
|
"rating": "sorts on annotation join columns",
|
||
|
|
"comment": "UI-sortable but rarely used; not worth an index",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
table: "album",
|
||
|
|
newRepo: func(ctx context.Context) *sqlRepository {
|
||
|
|
return &NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository).sqlRepository
|
||
|
|
},
|
||
|
|
exceptions: map[string]string{
|
||
|
|
"random": "not a column sort",
|
||
|
|
"starred_at": "sorts on annotation join columns",
|
||
|
|
"rated_at": "sorts on annotation join columns",
|
||
|
|
"max_year": "coalesce expression over original_date/max_year, no expression index",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
table: "artist",
|
||
|
|
newRepo: func(ctx context.Context) *sqlRepository {
|
||
|
|
return &NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository).sqlRepository
|
||
|
|
},
|
||
|
|
exceptions: map[string]string{ //nolint:gosec // G101 false positive, same as the artist sortMappings
|
||
|
|
"starred_at": "sorts on annotation join columns",
|
||
|
|
"rated_at": "sorts on annotation join columns",
|
||
|
|
"song_count": "JSON expression over stats column",
|
||
|
|
"album_count": "JSON expression over stats column",
|
||
|
|
"size": "JSON expression over stats column",
|
||
|
|
"maincredit_song_count": "aggregate over JSON stats",
|
||
|
|
"maincredit_album_count": "aggregate over JSON stats",
|
||
|
|
"maincredit_size": "aggregate over JSON stats",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
newCtx := func() context.Context {
|
||
|
|
ctx := log.NewContext(GinkgoT().Context())
|
||
|
|
return request.WithUser(ctx, model.User{ID: "userid"})
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, c := range cases {
|
||
|
|
It(fmt.Sprintf("uses an index for every sort mapping on %s", c.table), func() {
|
||
|
|
r := c.newRepo(newCtx())
|
||
|
|
for _, sort := range slices.Sorted(maps.Keys(r.sortMappings)) {
|
||
|
|
if _, ok := c.exceptions[sort]; ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
for _, dir := range []string{"asc", "desc"} {
|
||
|
|
orderBy := r.buildSortOrder(sort, dir)
|
||
|
|
Expect(checkSortUsesIndex(conn, c.table, orderBy)).To(Succeed(),
|
||
|
|
"sort %q (%s) on table %q needs an index. Create one matching its ORDER BY, or, if it cannot be served by an index, add it to exceptions with the reason",
|
||
|
|
sort, dir, c.table)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
It(fmt.Sprintf("has no stale exceptions entries for %s", c.table), func() {
|
||
|
|
r := c.newRepo(newCtx())
|
||
|
|
for _, sort := range slices.Sorted(maps.Keys(c.exceptions)) {
|
||
|
|
Expect(r.sortMappings).To(HaveKey(sort),
|
||
|
|
"exceptions entry %q on table %q does not match any sort mapping - remove it", sort, c.table)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
It("uses an index for recently_added when RecentlyAddedByModTime is enabled", func() {
|
||
|
|
DeferCleanup(configtest.SetupConfig())
|
||
|
|
conf.Server.RecentlyAddedByModTime = true
|
||
|
|
for _, c := range cases[:2] { // media_file and album
|
||
|
|
r := c.newRepo(newCtx())
|
||
|
|
for _, dir := range []string{"asc", "desc"} {
|
||
|
|
orderBy := r.buildSortOrder("recently_added", dir)
|
||
|
|
Expect(checkSortUsesIndex(conn, c.table, orderBy)).To(Succeed(),
|
||
|
|
"sort recently_added (%s) on table %q", dir, c.table)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
// Matches the full-sort step only: incremental tie-group sorts are reported as
|
||
|
|
// "USE TEMP B-TREE FOR LAST TERM OF ORDER BY" (or "LAST N TERMS") and are allowed.
|
||
|
|
var fullTempBTreeSort = regexp.MustCompile(`USE TEMP B-TREE FOR ORDER BY`)
|
||
|
|
|
||
|
|
func checkSortUsesIndex(conn *sql.DB, table, orderBy string) error {
|
||
|
|
rows, err := conn.Query(fmt.Sprintf("explain query plan select * from %s order by %s limit 15", table, orderBy))
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("explain query plan failed for order by %q: %w", orderBy, err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
var details []string
|
||
|
|
for rows.Next() {
|
||
|
|
var id, parent, notUsed int
|
||
|
|
var detail string
|
||
|
|
if err := rows.Scan(&id, &parent, ¬Used, &detail); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
details = append(details, detail)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if slices.ContainsFunc(details, fullTempBTreeSort.MatchString) {
|
||
|
|
return fmt.Errorf("no index satisfies ORDER BY %s - plan: %v", orderBy, details)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|