mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 10:27:08 +02:00
* fix(artwork): pause the artwork worker while a scan is running The artwork worker added in 0.64 writes to the database continuously, including while a scan runs. On slow storage the scanner holds the write lock for many seconds per folder, so the two writers keep timing each other out: artwork writes fail with "database is locked", and a single busy timeout on the scanner side aborts the whole scan. The worker now stops dispatching queue items while scanner.IsScanning reports true, including mid-batch, and resumes on the next poll after the scan ends. Artwork requests are unaffected, since they serve local art without the worker. * fix(db): run ANALYZE one index at a time so writers are not starved A full ANALYZE is a single write transaction, so every other write waits for it to finish and fails after the 15s busy timeout. On slow NAS storage it was measured taking over 26 minutes. The analysis now runs ANALYZE per index (per table for unindexed and WITHOUT ROWID tables), which produces the same sqlite_stat1 rows as a full ANALYZE, and pauses briefly between steps (up to 150ms, just above SQLite's longest busy-handler sleep) so waiting writers get the lock. * fix(scanner): ignore Synology @eaDir metadata folders Synology creates an @eaDir folder next to media files, holding one subfolder per file with generated thumbnails. The scanner and watcher treated them as regular folders, which on one reported library added tens of thousands of extra folders to every scan. * fix(db): analyze tables with only partial indexes as a whole A partial index does not record the table's row count, so a table whose only indexes are partial needs a table-level ANALYZE to get the sqlite_stat1 row a full ANALYZE would write. Navidrome's schema has no such table today, but the stepped analysis should match a full ANALYZE for any schema a future migration creates. * fix(scanner): retry busy folder saves and stop phase 1 on a fatal error On slow storage, a single SQLITE_BUSY while saving a folder aborted the whole scan, even when another writer held the lock only briefly. The folder save now runs as a retryable unit: on a busy error it waits (5s, 10s, 15s) and reruns the transaction, up to three times, before failing. Side effects that do not survive a rollback (the album ID map consumed by persistAlbum, the artwork queue items, the image-change record) are rebuilt per attempt or recorded only after a successful commit. When a folder save does fail, phase 1 used to keep walking the library and reading tags for every remaining folder, discarding the results, before reporting the error; a reporter saw 40 silent minutes. The walk now stops as soon as the save fails, and the walker honors cancellation instead of blocking on its channel. Because an early stop leaves folders unvisited, phase 1 no longer marks unvisited folders missing when the phase failed; the resumed scan handles them. * refactor(persistence): move busy retry into DataStore.WithTxRetry The scanner retried its folder save itself, which meant it had to know SQLite error codes. WithTxRetry now owns that policy: it reruns the block in a fresh transaction on SQLITE_BUSY, up to three times with growing delays, and runs it only once when already inside a transaction, since the outer transaction would still hold the lock. The block receives the context to use, and attempts that will be retried carry a marker so a busy statement in them is logged as a warning; only the final attempt logs errors. The scanner's inner error logs are folded into wrapped errors, so a recovered retry no longer prints error-level lines, and the folder path travels in the log context. * fix(persistence): join the enclosing transaction in a nested WithTxRetry Called on a store that is already inside a transaction, WithTxRetry went through WithTx, which opens a second, independent transaction on another connection. That transaction waits on the lock the outer one holds and fails with SQLITE_BUSY, and if it does succeed the outer transaction cannot roll it back. It now runs the block on the enclosing transaction, which owns the lock, the commit and the rollback. Found by a Codex (gpt-6-sol) review. * fix(scanner): retry the remaining scan writes on a busy database Every write step after phase 1 still aborted the whole scan on a single SQLITE_BUSY: phase 1 finalize, phase 2 moves and purge, phase 3 album saves and play count refreshes, the deferred playlist import flag, library ScanBegin, GC, the missing-artwork enqueue, tag counts, and the final library update. They now go through WithTxRetry. The phase 2 move had to be made rerun-safe first: it changed the target track's ID inside the transaction, so a rerun would have deleted the moved track itself, and it marked album annotations as handled even when the transaction rolled back. It now works on a copy per attempt and records the annotation reassignment only after a commit. Artist.RefreshStats is left alone: it updates artists in batches outside a transaction, and one transaction around all of them would hold the write lock for the whole refresh on slow storage. Phase 4 playlist imports go through the playlist service and are left for a follow-up. * fix(scanner): claim the album before moving its annotations The rerun-safe moveMatched checked processedAlbumAnnotations before its transaction and marked the album only after the commit. Phase 2 runs same-library and cross-library moves in separate pipeline stages, so two moves into one album could both pass the check; the second would reassign annotations again and overwrite the album's created_at. The album is now claimed under the lock before the transaction, as the old code effectively did, and the claim is released if the move fails so a later move can still reassign. Found by a Codex (gpt-6-sol) review. * fix(artwork): keep artwork housekeeping from writing during scans The artwork worker already pauses while a scan runs, but its housekeeping jobs did not: the hourly missing-artwork recheck (a bulk INSERT ... SELECT over albums and artists), the startup run of the same recheck, and the daily prune all kept competing with the scanner for the write lock. They now run through LockForMaintenance, like the scheduled DB analysis: they skip while a scan is running and keep a scan from starting until they finish. Skipping the recheck loses nothing, since each scan with changes queues missing artwork at its end. * refactor(scanner): log retried step errors once, from the caller Blocks passed to WithTxRetry still logged their own errors at error level on every attempt, so a busy error that a retry absorbed printed several error lines (GC printed three). They now return wrapped errors and the callers, which already log them, report the final outcome once. Also: drop a leftover variable in phase 1 finalize, check the walk context once, stop repeating the folder field that is already in the log context, stop shadowing finalize's err in phase 3, and format the WithTxRetry scope the same way as WithTx. * test(scanner): make the scanner suite's temp DB cleanup best effort Which DB file the process-wide DB handle opens depends on which spec touches it first. When the Scanner container wins the random order, its temp DB stays open until db.Close after RunSpecs, and on Windows removing the temp dir fails with 'being used by another process'. Ginkgo pins that on the container's last spec, which is now one of the busy-database specs. The sibling suites skip Windows for the same reason; this one now removes its temp dir on a best-effort basis instead, so it keeps running there.
651 lines
22 KiB
Go
651 lines
22 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"iter"
|
|
"reflect"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
. "github.com/Masterminds/squirrel"
|
|
"github.com/deluan/rest"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/db"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
id2 "github.com/navidrome/navidrome/model/id"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/utils/hasher"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
"github.com/pocketbase/dbx"
|
|
"github.com/zeebo/xxh3"
|
|
)
|
|
|
|
// sqlRepository is the base repository for all SQL repositories. It provides common functions to interact with the DB.
|
|
// When creating a new repository using this base, you must:
|
|
//
|
|
// - Embed this struct.
|
|
// - Set ctx and db fields. ctx should be the context passed to the constructor method, usually obtained from the request
|
|
// - Call registerModel with the model instance and any possible filters.
|
|
// - If the model has a different table name than the default (lowercase of the model name), it should be set manually
|
|
// using the tableName field.
|
|
// - Sort mappings must be set with setSortMappings method. If a sort field is not in the map, it will be used as the name of the column.
|
|
//
|
|
// All fields in filters and sortMappings must be in snake_case. Only sorts and filters based on real field names or
|
|
// defined in the mappings will be allowed.
|
|
type sqlRepository struct {
|
|
ctx context.Context
|
|
tableName string
|
|
db dbx.Builder
|
|
|
|
// Do not set these fields manually, they are set by the registerModel method
|
|
filterMappings map[string]filterFunc
|
|
isFieldWhiteListed fieldWhiteListedFunc
|
|
// Do not set this field manually, it is set by the setSortMappings method
|
|
sortMappings map[string]string
|
|
}
|
|
|
|
const invalidUserId = "-1"
|
|
|
|
func loggedUser(ctx context.Context) *model.User {
|
|
if user, ok := request.UserFrom(ctx); !ok {
|
|
return &model.User{ID: invalidUserId}
|
|
} else {
|
|
return &user
|
|
}
|
|
}
|
|
|
|
// ownerFilter returns the predicate restricting access to rows owned by the logged-in user, for
|
|
// tables with a user_id column. It returns nil for admins and for headless/system contexts (invalid
|
|
// user), meaning "no ownership restriction". Callers should skip the WHERE clause when it is nil.
|
|
//
|
|
// The predicate uses an unqualified user_id, so it only works on queries where that column is
|
|
// unambiguous (no join introducing a second user_id).
|
|
func (r sqlRepository) ownerFilter() Sqlizer {
|
|
if usr := loggedUser(r.ctx); !usr.IsAdmin && usr.ID != invalidUserId {
|
|
return Eq{"user_id": usr.ID}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// addRestriction combines an optional caller predicate with the ownership filter, producing the
|
|
// WHERE clause for owner-scoped reads. For admins and headless contexts ownerFilter() is nil and
|
|
// only the caller's predicate (if any) remains.
|
|
func (r sqlRepository) addRestriction(sql ...Sqlizer) Sqlizer {
|
|
s := And{}
|
|
if len(sql) > 0 {
|
|
s = append(s, sql[0])
|
|
}
|
|
if owner := r.ownerFilter(); owner != nil {
|
|
s = append(s, owner)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
|
|
if r.tableName == "" {
|
|
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")
|
|
r.tableName = toSnakeCase(r.tableName)
|
|
}
|
|
r.tableName = strings.ToLower(r.tableName)
|
|
r.isFieldWhiteListed = registerModelWhiteList(instance)
|
|
r.filterMappings = filters
|
|
}
|
|
|
|
// setSortMappings sets the mappings for the sort fields. If the sort field is not in the map, it will be used as is.
|
|
// This applies per comma-separated part, so a key added here also defines that bare name wherever a
|
|
// caller uses it inside a sort list.
|
|
//
|
|
// If PreferSortTags is enabled, it will map the order fields to the corresponding sort expression,
|
|
// which gives precedence to sort tags.
|
|
// Ex: order_title => (coalesce(nullif(sort_title,”),order_title) collate nocase)
|
|
// To avoid performance issues, indexes should be created for these sort expressions
|
|
//
|
|
// NOTE: if an individual item has spaces, it should be wrapped in parentheses. For example,
|
|
// you should write "(lyrics != '[]')". This prevents the item being split unexpectedly.
|
|
// Without parentheses, "lyrics != '[]'" would be mapped as simply "lyrics"
|
|
func (r *sqlRepository) setSortMappings(mappings map[string]string, tableName ...string) {
|
|
tn := r.tableName
|
|
if len(tableName) > 0 {
|
|
tn = tableName[0]
|
|
}
|
|
if conf.Server.PreferSortTags || conf.Server.EnableNaturalSorting {
|
|
for k, v := range mappings {
|
|
mappings[k] = mapSortOrder(tn, v)
|
|
}
|
|
}
|
|
r.sortMappings = mappings
|
|
}
|
|
|
|
func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder {
|
|
sq := Select().From(r.tableName)
|
|
if len(options) > 0 {
|
|
r.resetSeededRandom(options)
|
|
sq = r.applyOptions(sq, options...)
|
|
sq = r.applyFilters(sq, options...)
|
|
}
|
|
return sq
|
|
}
|
|
|
|
func (r sqlRepository) applyOptions(sq SelectBuilder, options ...model.QueryOptions) SelectBuilder {
|
|
if len(options) > 0 {
|
|
if options[0].Max > 0 {
|
|
sq = sq.Limit(uint64(options[0].Max))
|
|
}
|
|
if options[0].Offset > 0 {
|
|
sq = sq.Offset(uint64(options[0].Offset))
|
|
}
|
|
if options[0].Sort != "" {
|
|
sq = sq.OrderBy(r.buildSortOrder(options[0].Sort, options[0].Order))
|
|
}
|
|
}
|
|
return sq
|
|
}
|
|
|
|
// TODO Change all sortMappings to have a consistent case
|
|
func (r sqlRepository) sortMapping(sort string) string {
|
|
if mapping, _, ok := r.lookupSortMapping(sort); ok {
|
|
return mapping
|
|
}
|
|
// Each part of a comma list is resolved on its own, so a mix of mapped keys and plain columns
|
|
// keeps the mappings the recognized parts have.
|
|
parts := strings.FieldsFunc(sort, splitFunc(','))
|
|
mapped := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if partMapping, _, ok := r.lookupSortMapping(part); ok {
|
|
part = partMapping
|
|
} else {
|
|
part = toSnakeCase(part)
|
|
}
|
|
mapped = append(mapped, part)
|
|
}
|
|
return strings.Join(mapped, ", ")
|
|
}
|
|
|
|
// lookupSortMapping also returns the snake_case form when it had to derive one, so a caller's
|
|
// fallback doesn't recompute it: toSnakeCase runs two regexps.
|
|
func (r sqlRepository) lookupSortMapping(sort string) (mapping, snakeCased string, ok bool) {
|
|
if mapping, ok = r.sortMappings[sort]; ok {
|
|
return mapping, sort, true
|
|
}
|
|
if mapping, ok = r.sortMappings[toCamelCase(sort)]; ok {
|
|
return mapping, "", true
|
|
}
|
|
snakeCased = toSnakeCase(sort)
|
|
mapping, ok = r.sortMappings[snakeCased]
|
|
return mapping, snakeCased, ok
|
|
}
|
|
|
|
func (r sqlRepository) buildSortOrder(sort, order string) string {
|
|
sort = r.sortMapping(sort)
|
|
order = strings.ToLower(strings.TrimSpace(order))
|
|
var reverseOrder string
|
|
if order == "desc" {
|
|
reverseOrder = "asc"
|
|
} else {
|
|
order = "asc"
|
|
reverseOrder = "desc"
|
|
}
|
|
|
|
parts := strings.FieldsFunc(sort, splitFunc(','))
|
|
newSort := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
f := strings.FieldsFunc(p, splitFunc(' '))
|
|
newField := make([]string, 1, len(f))
|
|
newField[0] = f[0]
|
|
if len(f) == 1 {
|
|
newField = append(newField, order)
|
|
} else {
|
|
if f[1] == "asc" {
|
|
newField = append(newField, order)
|
|
} else {
|
|
newField = append(newField, reverseOrder)
|
|
}
|
|
}
|
|
newSort = append(newSort, strings.Join(newField, " "))
|
|
}
|
|
return strings.Join(newSort, ", ")
|
|
}
|
|
|
|
func splitFunc(delimiter rune) func(c rune) bool {
|
|
open := 0
|
|
return func(c rune) bool {
|
|
if c == '(' {
|
|
open++
|
|
return false
|
|
}
|
|
if open > 0 {
|
|
if c == ')' {
|
|
open--
|
|
}
|
|
return false
|
|
}
|
|
return c == delimiter
|
|
}
|
|
}
|
|
|
|
func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOptions) SelectBuilder {
|
|
if len(options) > 0 && options[0].Filters != nil {
|
|
sq = sq.Where(options[0].Filters)
|
|
}
|
|
return sq
|
|
}
|
|
|
|
// libraryIdFilter is a filter function to be added to resources that have a library_id column.
|
|
func libraryIdFilter(_ string, value any) Sqlizer {
|
|
return Eq{"library_id": value}
|
|
}
|
|
|
|
// applyLibraryFilter adds library filtering to queries for tables that have a library_id column
|
|
// This ensures users only see content from libraries they have access to
|
|
func (r sqlRepository) applyLibraryFilter(sq SelectBuilder, tableName ...string) SelectBuilder {
|
|
user := loggedUser(r.ctx)
|
|
|
|
// If the user is an admin, or the user ID is invalid (e.g., when no user is logged in), skip the library filter
|
|
if user.IsAdmin || user.ID == invalidUserId {
|
|
return sq
|
|
}
|
|
|
|
// A non-admin granted every library sees everything the subquery would return, so applying it is
|
|
// pure overhead. Skip it in that case (same fast path admins get).
|
|
if visible, err := r.visibleLibraryIDs(); err == nil && r.userSeesAllLibraries(visible) {
|
|
return sq
|
|
}
|
|
|
|
table := r.tableName
|
|
if len(tableName) > 0 {
|
|
table = tableName[0]
|
|
}
|
|
|
|
// Get user's accessible library IDs
|
|
// Use subquery to filter by user's library access
|
|
return sq.Where(Expr(table+".library_id IN ("+
|
|
"SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)", user.ID))
|
|
}
|
|
|
|
// userSeesAllLibraries reports whether the visible set already covers every library, so a
|
|
// library filter would exclude nothing.
|
|
func (r sqlRepository) userSeesAllLibraries(visible []int) bool {
|
|
user := loggedUser(r.ctx)
|
|
if user.IsAdmin || user.ID == invalidUserId {
|
|
return true // visible is the whole library table
|
|
}
|
|
total, err := NewLibraryRepository(r.ctx, r.db).CountAll()
|
|
if err != nil || total == 0 {
|
|
return false
|
|
}
|
|
return int64(len(visible)) == total
|
|
}
|
|
|
|
// visibleLibraryIDs returns the libraries the current user can see: all libraries for admin and
|
|
// headless processes, otherwise the user's granted libraries.
|
|
func (r sqlRepository) visibleLibraryIDs() ([]int, error) {
|
|
user := loggedUser(r.ctx)
|
|
if user.IsAdmin || user.ID == invalidUserId {
|
|
var ids []int
|
|
err := r.queryAllSlice(Select("id").From("library"), &ids)
|
|
return ids, err
|
|
}
|
|
return slice.Map(user.Libraries, func(lib model.Library) int { return lib.ID }), nil
|
|
}
|
|
|
|
func (r sqlRepository) seedKey() string {
|
|
// Seed keys must be all lowercase, or else SQLite3 will encode it, making it not match the seed
|
|
// used in the query. Hashing the user ID and converting it to a hex string will do the trick
|
|
userIDHash := xxh3.Hash([]byte(loggedUser(r.ctx).ID))
|
|
return fmt.Sprintf("%s|%016x", r.tableName, userIDHash)
|
|
}
|
|
|
|
func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) {
|
|
if len(options) == 0 || options[0].Sort != "random" {
|
|
return
|
|
}
|
|
// CAST: playlist_tracks.id is an INTEGER (unlike other tables' TEXT ids); passing it to
|
|
// SEEDEDRAND's string param uncast silently drops every row (go-sqlite3 binding gotcha).
|
|
options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', CAST(%s.id AS TEXT))", r.seedKey(), r.tableName)
|
|
if options[0].Seed != "" {
|
|
hasher.SetSeed(r.seedKey(), options[0].Seed)
|
|
return
|
|
}
|
|
if options[0].Offset == 0 {
|
|
hasher.Reseed(r.seedKey())
|
|
}
|
|
}
|
|
|
|
func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
|
|
query, args, err := r.toSQL(sq)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
start := time.Now()
|
|
var c int64
|
|
res, err := r.db.NewQuery(query).Bind(args).WithContext(r.ctx).Execute()
|
|
if res != nil {
|
|
c, _ = res.RowsAffected()
|
|
}
|
|
r.logSQL(query, args, err, c, start)
|
|
if err != nil {
|
|
if err.Error() != "LastInsertId is not supported by this driver" {
|
|
return 0, err
|
|
}
|
|
}
|
|
return c, err
|
|
}
|
|
|
|
var placeholderRegex = regexp.MustCompile(`\?`)
|
|
|
|
func (r sqlRepository) toSQL(sq Sqlizer) (string, dbx.Params, error) {
|
|
query, args, err := sq.ToSql()
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
// Replace query placeholders with named params
|
|
params := make(dbx.Params, len(args))
|
|
counter := 0
|
|
result := placeholderRegex.ReplaceAllStringFunc(query, func(_ string) string {
|
|
p := fmt.Sprintf("p%d", counter)
|
|
params[p] = args[counter]
|
|
counter++
|
|
return "{:" + p + "}"
|
|
})
|
|
return result, params, nil
|
|
}
|
|
|
|
func (r sqlRepository) queryOne(sq Sqlizer, response any) error {
|
|
query, args, err := r.toSQL(sq)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
start := time.Now()
|
|
err = r.db.NewQuery(query).Bind(args).WithContext(r.ctx).One(response)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
r.logSQL(query, args, nil, 0, start)
|
|
return model.ErrNotFound
|
|
}
|
|
r.logSQL(query, args, err, 1, start)
|
|
return err
|
|
}
|
|
|
|
// wrapCursor adapts a cursor over db rows into one over their models. toModel pulls out the row's
|
|
// embedded model, which a type parameter can't reach on its own.
|
|
func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.Seq2[T, error] {
|
|
return func(yield func(T, error) bool) {
|
|
for row, err := range cursor {
|
|
m := toModel(row)
|
|
if m == nil {
|
|
// Don't format row: its String() derefs the nil model (golang/go#81238).
|
|
var zero T
|
|
yield(zero, fmt.Errorf("unexpected nil %T: %w", zero, err))
|
|
return
|
|
}
|
|
if !yield(*m, err) || err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// queryWithStableResults is a helper function to execute a query and return an iterator that will yield its results
|
|
// from a cursor, guaranteeing that the results will be stable, even if the underlying data changes.
|
|
func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) {
|
|
if len(options) > 0 && options[0].Offset > 0 {
|
|
sq = r.optimizePagination(sq, options[0])
|
|
}
|
|
query, args, err := r.toSQL(sq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
start := time.Now()
|
|
rows, err := r.db.NewQuery(query).Bind(args).WithContext(r.ctx).Rows()
|
|
r.logSQL(query, args, err, -1, start)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return func(yield func(T, error) bool) {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var row T
|
|
err := rows.ScanStruct(&row)
|
|
if !yield(row, err) || err != nil {
|
|
return
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
var empty T
|
|
yield(empty, err)
|
|
}
|
|
}, nil
|
|
}
|
|
|
|
func (r sqlRepository) queryAll(sq SelectBuilder, response any, options ...model.QueryOptions) error {
|
|
if len(options) > 0 && options[0].Offset > 0 {
|
|
sq = r.optimizePagination(sq, options[0])
|
|
}
|
|
query, args, err := r.toSQL(sq)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
start := time.Now()
|
|
err = r.db.NewQuery(query).Bind(args).WithContext(r.ctx).All(response)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
r.logSQL(query, args, nil, -1, start)
|
|
return model.ErrNotFound
|
|
}
|
|
r.logSQL(query, args, err, int64(reflect.ValueOf(response).Elem().Len()), start)
|
|
return err
|
|
}
|
|
|
|
// queryAllSlice is a helper function to query a single column and return the result in a slice
|
|
func (r sqlRepository) queryAllSlice(sq SelectBuilder, response any) error {
|
|
query, args, err := r.toSQL(sq)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
start := time.Now()
|
|
err = r.db.NewQuery(query).Bind(args).WithContext(r.ctx).Column(response)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
r.logSQL(query, args, nil, -1, start)
|
|
return model.ErrNotFound
|
|
}
|
|
r.logSQL(query, args, err, int64(reflect.ValueOf(response).Elem().Len()), start)
|
|
return err
|
|
}
|
|
|
|
// optimizePagination uses a less inefficient pagination, by not using OFFSET.
|
|
// See https://gist.github.com/ssokolow/262503
|
|
func (r sqlRepository) optimizePagination(sq SelectBuilder, options model.QueryOptions) SelectBuilder {
|
|
if options.Offset > conf.Server.DevOffsetOptimize {
|
|
sq = sq.RemoveOffset()
|
|
rowidSq := sq.RemoveColumns().Columns(r.tableName + ".rowid")
|
|
rowidSq = rowidSq.Limit(uint64(options.Offset))
|
|
rowidSql, args, _ := rowidSq.ToSql()
|
|
sq = sq.Where(r.tableName+".rowid not in ("+rowidSql+")", args...)
|
|
}
|
|
return sq
|
|
}
|
|
|
|
func (r sqlRepository) exists(cond Sqlizer) (bool, error) {
|
|
existsQuery := Select("count(*) as exist").From(r.tableName).Where(cond)
|
|
var res struct{ Exist int64 }
|
|
err := r.queryOne(existsQuery, &res)
|
|
return res.Exist > 0, err
|
|
}
|
|
|
|
// updateOwned performs an atomic, ownership-restricted update of the row identified by id, for
|
|
// repositories whose table has a user_id column. Non-admins can only update rows they own: the
|
|
// ownership predicate is part of the UPDATE's WHERE clause, so a row owned by another user simply
|
|
// does not match and no write happens. Ownership itself is immutable here: user_id is never written,
|
|
// so no caller (admin included) can reassign a row to a different owner via an update. Unlike put,
|
|
// it never falls through to an INSERT, so a non-matching id never creates a row.
|
|
//
|
|
// When the update matches no row it classifies the failure: if the row exists but is owned by
|
|
// another user it returns rest.ErrPermissionDenied, otherwise rest.ErrNotFound. The write itself is
|
|
// still atomic; the extra lookup happens only on the failure path (count == 0), where no write
|
|
// occurred, so there is no TOCTOU on the update.
|
|
func (r sqlRepository) updateOwned(id string, m any, colsToUpdate ...string) error {
|
|
values, err := toSQLArgs(m)
|
|
if err != nil {
|
|
return fmt.Errorf("error preparing values to write to DB: %w", err)
|
|
}
|
|
updateValues := filterUpdateValues(values, id, colsToUpdate...)
|
|
delete(updateValues, "user_id") // ownership is immutable on update
|
|
update := Update(r.tableName).Where(r.addRestriction(Eq{"id": id})).SetMap(updateValues)
|
|
count, err := r.executeSQL(update)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return r.classifyOwnedWriteMiss(id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// deleteOwned performs an atomic, ownership-restricted delete of the row identified by id, for
|
|
// repositories whose table has a user_id column. Non-admins can only delete rows they own: the
|
|
// ownership predicate is part of the DELETE's WHERE clause, so a row owned by another user simply
|
|
// does not match and is left untouched. The failure path mirrors updateOwned (see
|
|
// classifyOwnedWriteMiss), so there is no TOCTOU on the delete.
|
|
func (r sqlRepository) deleteOwned(id string) error {
|
|
count, err := r.executeSQL(Delete(r.tableName).Where(r.addRestriction(Eq{"id": id})))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return r.classifyOwnedWriteMiss(id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// classifyOwnedWriteMiss explains why an ownership-filtered write (updateOwned/deleteOwned) matched
|
|
// no row: rest.ErrPermissionDenied if the row exists but is owned by another user, otherwise
|
|
// rest.ErrNotFound. It runs only on the failure path (count == 0), where no write occurred.
|
|
func (r sqlRepository) classifyOwnedWriteMiss(id string) error {
|
|
exists, err := r.exists(Eq{"id": id})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if exists {
|
|
return rest.ErrPermissionDenied
|
|
}
|
|
return rest.ErrNotFound
|
|
}
|
|
|
|
func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) {
|
|
countQuery = countQuery.
|
|
RemoveColumns().Columns("count(distinct " + r.tableName + ".id) as count").
|
|
RemoveOffset().RemoveLimit().
|
|
OrderBy(r.tableName + ".id"). // To remove any ORDER BY clause that could slow down the query
|
|
From(r.tableName)
|
|
countQuery = r.applyFilters(countQuery, options...)
|
|
var res struct{ Count int64 }
|
|
err := r.queryOne(countQuery, &res)
|
|
return res.Count, err
|
|
}
|
|
|
|
func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate ...string) (string, error) {
|
|
if id != "" {
|
|
return r.put(id, m, colsToUpdate...)
|
|
}
|
|
existsQuery := r.newSelect().Columns("id").From(r.tableName).Where(filter)
|
|
|
|
var res struct{ ID string }
|
|
err := r.queryOne(existsQuery, &res)
|
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
return "", err
|
|
}
|
|
return r.put(res.ID, m, colsToUpdate...)
|
|
}
|
|
|
|
// selectUpdateColumns keeps only the requested colsToUpdate (or all columns when none are
|
|
// specified), dropping columns that must never be overwritten on update (created_at, birth_time).
|
|
func selectUpdateColumns(values map[string]any, colsToUpdate ...string) map[string]any {
|
|
updateValues := map[string]any{}
|
|
|
|
c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) {
|
|
return toSnakeCase(s), struct{}{}
|
|
})
|
|
for k, v := range values {
|
|
if _, found := c2upd[k]; len(c2upd) == 0 || found {
|
|
updateValues[k] = v
|
|
}
|
|
}
|
|
|
|
delete(updateValues, "created_at")
|
|
// To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now
|
|
// TODO move to mediafile_repository when each repo has its own upsert method
|
|
delete(updateValues, "birth_time")
|
|
return updateValues
|
|
}
|
|
|
|
func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any {
|
|
updateValues := selectUpdateColumns(values, colsToUpdate...)
|
|
updateValues["id"] = id
|
|
return updateValues
|
|
}
|
|
|
|
func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) {
|
|
values, err := toSQLArgs(m)
|
|
if err != nil {
|
|
return "", fmt.Errorf("error preparing values to write to DB: %w", err)
|
|
}
|
|
// If there's an ID, try to update first
|
|
if id != "" {
|
|
update := Update(r.tableName).Where(Eq{"id": id}).SetMap(filterUpdateValues(values, id, colsToUpdate...))
|
|
count, err := r.executeSQL(update)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if count > 0 {
|
|
return id, nil
|
|
}
|
|
}
|
|
// If it does not have an ID OR the ID was not found (when it is a new record with predefined id)
|
|
if id == "" {
|
|
id = id2.NewRandom()
|
|
values["id"] = id
|
|
}
|
|
insert := Insert(r.tableName).SetMap(values)
|
|
_, err = r.executeSQL(insert)
|
|
return id, err
|
|
}
|
|
|
|
func (r sqlRepository) delete(cond Sqlizer) error {
|
|
_, err := r.executeSQL(Delete(r.tableName).Where(cond))
|
|
return err
|
|
}
|
|
|
|
// deleteByID is for single-item deletes that must report a missing row; delete succeeds silently.
|
|
func (r sqlRepository) deleteByID(id string) error {
|
|
count, err := r.executeSQL(Delete(r.tableName).Where(Eq{"id": id}))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return model.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r sqlRepository) logSQL(sql string, args dbx.Params, err error, rowsAffected int64, start time.Time) {
|
|
elapsed := time.Since(start)
|
|
fields := []any{r.ctx, "SQL: `" + sql + "`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed}
|
|
if err == nil || errors.Is(err, context.Canceled) {
|
|
log.Trace(append(fields, err)...)
|
|
return
|
|
}
|
|
// The result codes separate errors that share a message, notably SQLITE_BUSY from
|
|
// SQLITE_BUSY_SNAPSHOT, which no busy_timeout can retry.
|
|
if code, extended, ok := db.ErrorCodes(err); ok {
|
|
fields = append(fields, "sqliteCode", code, "sqliteExtended", extended)
|
|
}
|
|
if db.IsBusy(err) && hasBusyRetry(r.ctx) {
|
|
log.Warn(append(fields, err)...)
|
|
return
|
|
}
|
|
log.Error(append(fields, err)...)
|
|
}
|