mirror of
https://github.com/photoprism/photoprism.git
synced 2026-01-23 02:24:24 +00:00
This commit also normalizes the map key names for the GORM Updates and UpdateColumns calls to use the database column names. Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
34bf6e4e26
commit
7e6dabc9ad
24 changed files with 903 additions and 90 deletions
|
|
@ -839,7 +839,7 @@ func (m *User) NoScope() bool {
|
|||
// UpdateScope updates optional user account scope.
|
||||
func (m *User) UpdateScope(scope string) error {
|
||||
m.UserScope = clean.Scope(scope)
|
||||
return m.Updates(Values{"UserScope": m.UserScope})
|
||||
return m.Updates(Values{"user_scope": m.UserScope})
|
||||
}
|
||||
|
||||
// Attr returns optional user account attributes as sanitized string.
|
||||
|
|
@ -851,7 +851,7 @@ func (m *User) Attr() string {
|
|||
// UpdateAttr updates optional user account attributes.
|
||||
func (m *User) UpdateAttr(attr string) error {
|
||||
m.UserAttr = clean.Attr(attr)
|
||||
return m.Updates(Values{"UserAttr": m.UserAttr})
|
||||
return m.Updates(Values{"user_attr": m.UserAttr})
|
||||
}
|
||||
|
||||
// IsRegistered checks if this user has a registered account with a valid ID, username, and role.
|
||||
|
|
@ -1218,7 +1218,7 @@ func (m *User) RegenerateTokens() error {
|
|||
|
||||
m.GenerateTokens(true)
|
||||
|
||||
return m.Updates(Values{"PreviewToken": m.PreviewToken, "DownloadToken": m.DownloadToken})
|
||||
return m.Updates(Values{"preview_token": m.PreviewToken, "download_token": m.DownloadToken})
|
||||
}
|
||||
|
||||
// RefreshShares updates the list of shares.
|
||||
|
|
@ -1483,5 +1483,5 @@ func (m *User) SetAvatar(thumb, thumbSrc string) error {
|
|||
m.Thumb = thumb
|
||||
m.ThumbSrc = thumbSrc
|
||||
|
||||
return m.Updates(Values{"Thumb": m.Thumb, "ThumbSrc": m.ThumbSrc})
|
||||
return m.Updates(Values{"thumb": m.Thumb, "thumb_src": m.ThumbSrc})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ func (m *Face) SetEmbeddings(embeddings face.Embeddings) (err error) {
|
|||
// Matched updates the match timestamp.
|
||||
func (m *Face) Matched() error {
|
||||
m.MatchedAt = TimeStamp()
|
||||
return UnscopedDb().Model(m).UpdateColumns(Values{"MatchedAt": m.MatchedAt}).Error
|
||||
return UnscopedDb().Model(m).UpdateColumns(Values{"matched_at": m.MatchedAt}).Error
|
||||
}
|
||||
|
||||
// Embedding returns parsed face embedding.
|
||||
|
|
@ -199,7 +199,8 @@ func (m *Face) ResolveCollision(embeddings face.Embeddings) (resolved bool, err
|
|||
m.Collisions++
|
||||
m.CollisionRadius = dist
|
||||
UpdateFaces.Store(true)
|
||||
return true, m.Updates(Values{"Collisions": m.Collisions, "CollisionRadius": m.CollisionRadius, "FaceKind": m.FaceKind, "UpdatedAt": m.UpdatedAt, "MatchedAt": m.MatchedAt})
|
||||
return true, m.Updates(Values{"collisions": m.Collisions, "collision_radius": m.CollisionRadius,
|
||||
"face_kind": m.FaceKind, "updated_at": m.UpdatedAt, "matched_at": m.MatchedAt})
|
||||
} else {
|
||||
m.MatchedAt = nil
|
||||
m.Collisions++
|
||||
|
|
@ -207,7 +208,7 @@ func (m *Face) ResolveCollision(embeddings face.Embeddings) (resolved bool, err
|
|||
UpdateFaces.Store(true)
|
||||
}
|
||||
|
||||
err = m.Updates(Values{"Collisions": m.Collisions, "CollisionRadius": m.CollisionRadius, "MatchedAt": m.MatchedAt})
|
||||
err = m.Updates(Values{"collisions": m.Collisions, "collision_radius": m.CollisionRadius, "matched_at": m.MatchedAt})
|
||||
|
||||
if err != nil {
|
||||
return true, err
|
||||
|
|
@ -302,7 +303,7 @@ func (m *Face) UpdateMatchStats(samples int, maxDistance float64) error {
|
|||
m.SampleRadius = radius
|
||||
UpdateFaces.Store(true)
|
||||
|
||||
return m.Updates(Values{"Samples": m.Samples, "SampleRadius": m.SampleRadius})
|
||||
return m.Updates(Values{"samples": m.Samples, "sample_radius": m.SampleRadius})
|
||||
}
|
||||
|
||||
// SetSubjectUID updates the face's subject uid and related markers.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
|
|
@ -32,14 +35,32 @@ func NewKeyword(keyword string) *Keyword {
|
|||
return result
|
||||
}
|
||||
|
||||
// Updates multiple columns in the database.
|
||||
func (m *Keyword) Updates(values interface{}) error {
|
||||
return UnscopedDb().Model(m).UpdateColumns(values).Error
|
||||
// Update modifies a single column on an already persisted keyword and relies on
|
||||
// the standard GORM callback to evict the cached instance afterwards.
|
||||
func (m *Keyword) Update(attr string, value interface{}) error {
|
||||
if m == nil {
|
||||
return errors.New("keyword must not be nil - you may have found a bug")
|
||||
} else if !m.HasID() {
|
||||
return errors.New("keyword ID must not be empty - you may have found a bug")
|
||||
}
|
||||
|
||||
// Omit FlushCachedKeyword() because this should automatically trigger the AfterUpdate() hook.
|
||||
return UnscopedDb().Model(m).Update(attr, value).Error
|
||||
}
|
||||
|
||||
// Update a column in the database.
|
||||
func (m *Keyword) Update(attr string, value interface{}) error {
|
||||
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
|
||||
// Updates applies a set of column changes to an existing keyword while keeping
|
||||
// the cache consistent via the AfterUpdate hook.
|
||||
func (m *Keyword) Updates(values interface{}) error {
|
||||
if values == nil {
|
||||
return nil
|
||||
} else if m == nil {
|
||||
return errors.New("keyword must not be nil - you may have found a bug")
|
||||
} else if !m.HasID() {
|
||||
return errors.New("keyword ID must not be empty - you may have found a bug")
|
||||
}
|
||||
|
||||
// Omit FlushCachedKeyword() because this should automatically trigger the AfterUpdate() hook.
|
||||
return UnscopedDb().Model(m).Updates(values).Error
|
||||
}
|
||||
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
|
|
@ -55,16 +76,41 @@ func (m *Keyword) Create() error {
|
|||
return Db().Create(m).Error
|
||||
}
|
||||
|
||||
// AfterUpdate flushes the cache when the entity is updated.
|
||||
func (m *Keyword) AfterUpdate(tx *gorm.DB) (err error) {
|
||||
FlushCachedKeyword(m)
|
||||
return
|
||||
}
|
||||
|
||||
// AfterDelete flushes the cache when the entity is deleted.
|
||||
func (m *Keyword) AfterDelete(tx *gorm.DB) (err error) {
|
||||
FlushCachedKeyword(m)
|
||||
return
|
||||
}
|
||||
|
||||
// AfterCreate flushes the cache when the entity is created.
|
||||
func (m *Keyword) AfterCreate(scope *gorm.Scope) error {
|
||||
FlushCachedKeyword(m)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasID reports whether the keyword has already been persisted and assigned
|
||||
// a primary key so callers can skip duplicate writes or lookups.
|
||||
func (m *Keyword) HasID() bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
return m.ID > 0
|
||||
}
|
||||
|
||||
// FirstOrCreateKeyword returns the existing row, inserts a new row or nil in case of errors.
|
||||
func FirstOrCreateKeyword(m *Keyword) *Keyword {
|
||||
result := Keyword{}
|
||||
|
||||
if err := Db().Where("keyword = ?", m.Keyword).First(&result).Error; err == nil {
|
||||
return &result
|
||||
if result, err := FindKeyword(m.Keyword, true); err == nil {
|
||||
return result
|
||||
} else if createErr := m.Create(); createErr == nil {
|
||||
return m
|
||||
} else if err := Db().Where("keyword = ?", m.Keyword).First(&result).Error; err == nil {
|
||||
return &result
|
||||
} else if result, err = FindKeyword(m.Keyword, false); err == nil {
|
||||
return result
|
||||
} else {
|
||||
log.Errorf("keyword: %s (find or create %s)", createErr, m.Keyword)
|
||||
}
|
||||
|
|
|
|||
179
internal/entity/keyword_cache.go
Normal file
179
internal/entity/keyword_cache.go
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize/english"
|
||||
gc "github.com/patrickmn/go-cache"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
// Keyword and PhotoKeyword cache expiration times and cleanup interval.
|
||||
const (
|
||||
keywordCacheDefaultExpiration = 15 * time.Minute
|
||||
keywordCacheErrorExpiration = 5 * time.Minute
|
||||
keywordCacheCleanupInterval = 10 * time.Minute
|
||||
photoKeywordCacheExpiration = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Cache Keyword and PhotoKeyword entities for faster indexing.
|
||||
var (
|
||||
keywordCache = gc.New(keywordCacheDefaultExpiration, keywordCacheCleanupInterval)
|
||||
photoKeywordCache = gc.New(photoKeywordCacheExpiration, keywordCacheCleanupInterval)
|
||||
photoKeywordCacheMutex = sync.Mutex{}
|
||||
)
|
||||
|
||||
// photoKeywordCacheKey returns a string key for the photoKeywordCache.
|
||||
func photoKeywordCacheKey(photoId, keywordId uint) string {
|
||||
return fmt.Sprintf("%d-%d", photoId, keywordId)
|
||||
}
|
||||
|
||||
// FlushKeywordCache removes all cached Keyword entities from the cache.
|
||||
func FlushKeywordCache() {
|
||||
keywordCache.Flush()
|
||||
}
|
||||
|
||||
// FlushCachedKeyword deletes a cached Keyword entity from the cache.
|
||||
func FlushCachedKeyword(m *Keyword) {
|
||||
if m == nil {
|
||||
return
|
||||
} else if m.HasID() {
|
||||
keywordCache.Delete(m.Keyword)
|
||||
}
|
||||
}
|
||||
|
||||
// FlushPhotoKeywordCache removes all cached PhotoKeyword entities from the cache.
|
||||
func FlushPhotoKeywordCache() {
|
||||
photoKeywordCacheMutex.Lock()
|
||||
defer photoKeywordCacheMutex.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
photoKeywordCache.Flush()
|
||||
|
||||
log.Debugf("index: flushed photo keywords cache [%s]", time.Since(start))
|
||||
}
|
||||
|
||||
// FlushCachedPhotoKeyword deletes a cached PhotoKeyword entity from the cache.
|
||||
func FlushCachedPhotoKeyword(m *PhotoKeyword) {
|
||||
if m == nil {
|
||||
return
|
||||
} else if m.HasID() {
|
||||
photoKeywordCache.Delete(photoKeywordCacheKey(m.PhotoID, m.KeywordID))
|
||||
}
|
||||
}
|
||||
|
||||
// CachePhotoKeywords preloads the photo-keyword cache from the database to speed up lookups.
|
||||
func CachePhotoKeywords() (err error) {
|
||||
photoKeywordCacheMutex.Lock()
|
||||
defer photoKeywordCacheMutex.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
var photoKeywords []PhotoKeyword
|
||||
|
||||
// Find photo keyword assignments.
|
||||
if err = UnscopedDb().
|
||||
Raw("SELECT * FROM photos_keywords").
|
||||
Scan(&photoKeywords).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Cache existing keyword assignments.
|
||||
for _, m := range photoKeywords {
|
||||
photoKeywordCache.SetDefault(m.CacheKey(), m)
|
||||
}
|
||||
|
||||
log.Debugf("index: cached %s [%s]", english.Plural(len(photoKeywords), "photo keyword", "photo keywords"), time.Since(start))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindKeyword resolves a keyword by its normalized name, optionally consulting
|
||||
// the in-memory cache before hitting the database.
|
||||
func FindKeyword(keyword string, cached bool) (*Keyword, error) {
|
||||
if keyword == "" {
|
||||
return &Keyword{}, errors.New("missing keyword name")
|
||||
}
|
||||
|
||||
// Return cached keyword, if found.
|
||||
if cached {
|
||||
if cacheData, ok := keywordCache.Get(keyword); ok {
|
||||
log.Tracef("keyword: cache hit for %s", keyword)
|
||||
|
||||
// Get cached data.
|
||||
if result := cacheData.(*Keyword); result.HasID() {
|
||||
// Return cached entity.
|
||||
return result, nil
|
||||
} else {
|
||||
// Return cached "not found" error.
|
||||
return &Keyword{}, fmt.Errorf("keyword not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch and cache keyword.
|
||||
result := &Keyword{}
|
||||
|
||||
if find := Db().First(result, "keyword = ?", keyword); find.RecordNotFound() {
|
||||
keywordCache.Set(keyword, result, keywordCacheErrorExpiration)
|
||||
return result, fmt.Errorf("keyword not found")
|
||||
} else if find.Error != nil {
|
||||
keywordCache.Set(keyword, result, keywordCacheErrorExpiration)
|
||||
return result, find.Error
|
||||
} else {
|
||||
keywordCache.SetDefault(result.Keyword, result)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FindPhotoKeyword loads the photo-keyword join row for the given IDs, using the cache when enabled.
|
||||
func FindPhotoKeyword(photoId, keywordId uint, cached bool) (*PhotoKeyword, error) {
|
||||
if photoId == 0 {
|
||||
return &PhotoKeyword{}, errors.New("invalid photo id")
|
||||
} else if keywordId == 0 {
|
||||
return &PhotoKeyword{}, errors.New("invalid keyword id")
|
||||
}
|
||||
|
||||
cacheKey := photoKeywordCacheKey(photoId, keywordId)
|
||||
|
||||
if cacheKey == "" {
|
||||
return &PhotoKeyword{}, fmt.Errorf("invalid cache key %s", clean.LogQuote(cacheKey))
|
||||
}
|
||||
|
||||
// Return cached keyword, if found.
|
||||
if cached {
|
||||
if cacheData, ok := photoKeywordCache.Get(cacheKey); ok {
|
||||
log.Tracef("photo-keyword: cache hit for %s", cacheKey)
|
||||
|
||||
// Get cached data.
|
||||
if result := cacheData.(PhotoKeyword); result.HasID() {
|
||||
// Return cached entity.
|
||||
return &result, nil
|
||||
} else {
|
||||
// Return cached "not found" error.
|
||||
return &PhotoKeyword{}, fmt.Errorf("photo-keyword not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch and cache photo-keyword.
|
||||
result := &PhotoKeyword{}
|
||||
|
||||
if find := Db().First(result, "photo_id = ? AND keyword_id = ?", photoId, keywordId); find.RecordNotFound() {
|
||||
photoKeywordCache.Set(cacheKey, *result, keywordCacheErrorExpiration)
|
||||
return result, fmt.Errorf("photo-keyword not found")
|
||||
} else if find.Error != nil {
|
||||
photoKeywordCache.Set(cacheKey, *result, keywordCacheErrorExpiration)
|
||||
return result, find.Error
|
||||
} else {
|
||||
photoKeywordCache.SetDefault(cacheKey, *result)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
116
internal/entity/keyword_cache_test.go
Normal file
116
internal/entity/keyword_cache_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFindKeyword(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
FlushKeywordCache()
|
||||
|
||||
keywordName := fmt.Sprintf("keyword-cache-%d", time.Now().UnixNano())
|
||||
keyword := NewKeyword(keywordName)
|
||||
|
||||
require.NoError(t, keyword.Save())
|
||||
|
||||
uncached, err := FindKeyword(keyword.Keyword, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, keyword.Keyword, uncached.Keyword)
|
||||
assert.Equal(t, keyword.ID, uncached.ID)
|
||||
|
||||
cached, err := FindKeyword(keyword.Keyword, true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, keyword.Keyword, cached.Keyword)
|
||||
assert.Equal(t, keyword.ID, cached.ID)
|
||||
})
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
FlushKeywordCache()
|
||||
|
||||
missingName := fmt.Sprintf("missing-keyword-%d", time.Now().UnixNano())
|
||||
result, err := FindKeyword(missingName, true)
|
||||
assert.Error(t, err)
|
||||
assert.NotNil(t, result)
|
||||
|
||||
result, err = FindKeyword(missingName, false)
|
||||
assert.Error(t, err)
|
||||
assert.NotNil(t, result)
|
||||
})
|
||||
t.Run("EmptyName", func(t *testing.T) {
|
||||
FlushKeywordCache()
|
||||
|
||||
result, err := FindKeyword("", true)
|
||||
assert.Error(t, err)
|
||||
assert.NotNil(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFindPhotoKeyword(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
FlushPhotoKeywordCache()
|
||||
require.NoError(t, CachePhotoKeywords())
|
||||
|
||||
fixture := PhotoKeywordFixtures["3"]
|
||||
cached, err := FindPhotoKeyword(fixture.PhotoID, fixture.KeywordID, true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, fixture.KeywordID, cached.KeywordID)
|
||||
assert.Equal(t, fixture.PhotoID, cached.PhotoID)
|
||||
|
||||
FlushPhotoKeywordCache()
|
||||
cached, err = FindPhotoKeyword(fixture.PhotoID, fixture.KeywordID, true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, fixture.KeywordID, cached.KeywordID)
|
||||
assert.Equal(t, fixture.PhotoID, cached.PhotoID)
|
||||
})
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
FlushPhotoKeywordCache()
|
||||
missingPhotoID := uint(5000000)
|
||||
missingKeywordID := uint(6000000)
|
||||
|
||||
result, err := FindPhotoKeyword(missingPhotoID, missingKeywordID, true)
|
||||
assert.Error(t, err)
|
||||
assert.NotNil(t, result)
|
||||
|
||||
result, err = FindPhotoKeyword(missingPhotoID, missingKeywordID, false)
|
||||
assert.Error(t, err)
|
||||
assert.NotNil(t, result)
|
||||
})
|
||||
t.Run("InvalidID", func(t *testing.T) {
|
||||
FlushPhotoKeywordCache()
|
||||
result, err := FindPhotoKeyword(0, 0, true)
|
||||
assert.Error(t, err)
|
||||
assert.NotNil(t, result)
|
||||
|
||||
result, err = FindPhotoKeyword(0, 0, false)
|
||||
assert.Error(t, err)
|
||||
assert.NotNil(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCachePhotoKeywords(t *testing.T) {
|
||||
FlushPhotoKeywordCache()
|
||||
require.NoError(t, CachePhotoKeywords())
|
||||
|
||||
fixture := PhotoKeywordFixtures["3"]
|
||||
_, found := photoKeywordCache.Get(photoKeywordCacheKey(fixture.PhotoID, fixture.KeywordID))
|
||||
assert.True(t, found)
|
||||
}
|
||||
|
||||
func TestFlushCachedPhotoKeyword(t *testing.T) {
|
||||
FlushPhotoKeywordCache()
|
||||
fixture := PhotoKeywordFixtures["3"]
|
||||
cacheKey := photoKeywordCacheKey(fixture.PhotoID, fixture.KeywordID)
|
||||
photoKeywordCache.SetDefault(cacheKey, fixture)
|
||||
|
||||
FlushCachedPhotoKeyword(&fixture)
|
||||
|
||||
_, found := photoKeywordCache.Get(cacheKey)
|
||||
assert.False(t, found)
|
||||
|
||||
// Ensure nil inputs are safe.
|
||||
FlushCachedPhotoKeyword(nil)
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewKeyword(t *testing.T) {
|
||||
|
|
@ -19,6 +22,11 @@ func TestNewKeyword(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestKeyword_TableName(t *testing.T) {
|
||||
keyword := &Keyword{}
|
||||
assert.Equal(t, "keywords", keyword.TableName())
|
||||
}
|
||||
|
||||
func TestFirstOrCreateKeyword(t *testing.T) {
|
||||
keyword := NewKeyword("food")
|
||||
result := FirstOrCreateKeyword(keyword)
|
||||
|
|
@ -36,6 +44,7 @@ func TestKeyword_Updates(t *testing.T) {
|
|||
t.Run("Success", func(t *testing.T) {
|
||||
keyword := NewKeyword("KeywordBeforeUpdate")
|
||||
|
||||
assert.NoError(t, keyword.Save())
|
||||
assert.Equal(t, "keywordbeforeupdate", keyword.Keyword)
|
||||
|
||||
err := keyword.Updates(Keyword{Keyword: "KeywordAfterUpdate", ID: 999})
|
||||
|
|
@ -43,14 +52,31 @@ func TestKeyword_Updates(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "KeywordAfterUpdate", keyword.Keyword)
|
||||
assert.Equal(t, uint(0x3e7), keyword.ID)
|
||||
})
|
||||
t.Run("NilValues", func(t *testing.T) {
|
||||
keyword := NewKeyword("noop")
|
||||
assert.NoError(t, keyword.Updates(nil))
|
||||
})
|
||||
t.Run("NilKeyword", func(t *testing.T) {
|
||||
var keyword *Keyword
|
||||
err := keyword.Updates(Keyword{Keyword: "value"})
|
||||
assert.EqualError(t, err, "keyword must not be nil - you may have found a bug")
|
||||
})
|
||||
t.Run("MissingID", func(t *testing.T) {
|
||||
keyword := NewKeyword("missing-id")
|
||||
err := keyword.Updates(Keyword{Keyword: "value"})
|
||||
assert.EqualError(t, err, "keyword ID must not be empty - you may have found a bug")
|
||||
})
|
||||
}
|
||||
|
||||
func TestKeyword_Update(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
keyword := NewKeyword("KeywordBeforeUpdate2")
|
||||
|
||||
require.NoError(t, keyword.Save())
|
||||
assert.Equal(t, "keywordbeforeupdate2", keyword.Keyword)
|
||||
|
||||
err := keyword.Update("Keyword", "new-name")
|
||||
|
|
@ -58,8 +84,30 @@ func TestKeyword_Update(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, "new-name", keyword.Keyword)
|
||||
|
||||
assert.Equal(t, "new-name", keyword.Keyword)
|
||||
})
|
||||
t.Run("NilKeyword", func(t *testing.T) {
|
||||
var keyword *Keyword
|
||||
err := keyword.Update("Keyword", "value")
|
||||
assert.EqualError(t, err, "keyword must not be nil - you may have found a bug")
|
||||
})
|
||||
t.Run("MissingID", func(t *testing.T) {
|
||||
keyword := NewKeyword("missing-id")
|
||||
err := keyword.Update("Keyword", "value")
|
||||
assert.EqualError(t, err, "keyword ID must not be empty - you may have found a bug")
|
||||
})
|
||||
t.Run("FlushesCache", func(t *testing.T) {
|
||||
FlushKeywordCache()
|
||||
keyword := NewKeyword(fmt.Sprintf("cache-update-%d", time.Now().UnixNano()))
|
||||
require.NoError(t, keyword.Save())
|
||||
|
||||
keywordCache.SetDefault(keyword.Keyword, keyword)
|
||||
|
||||
require.NoError(t, keyword.Update("Skip", true))
|
||||
|
||||
_, found := keywordCache.Get(keyword.Keyword)
|
||||
assert.False(t, found)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -74,3 +122,60 @@ func TestKeyword_Save(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFlushCachedKeyword(t *testing.T) {
|
||||
t.Run("DeletesCachedEntry", func(t *testing.T) {
|
||||
FlushKeywordCache()
|
||||
keyword := NewKeyword(fmt.Sprintf("flush-%d", time.Now().UnixNano()))
|
||||
require.NoError(t, keyword.Save())
|
||||
|
||||
keywordCache.SetDefault(keyword.Keyword, keyword)
|
||||
|
||||
FlushCachedKeyword(keyword)
|
||||
|
||||
_, found := keywordCache.Get(keyword.Keyword)
|
||||
assert.False(t, found)
|
||||
})
|
||||
t.Run("NilKeyword", func(t *testing.T) {
|
||||
FlushCachedKeyword(nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestKeyword_Create(t *testing.T) {
|
||||
FlushKeywordCache()
|
||||
keyword := NewKeyword(fmt.Sprintf("keyword-create-%d", time.Now().UnixNano()))
|
||||
require.NoError(t, keyword.Create())
|
||||
assert.True(t, keyword.HasID())
|
||||
var fetched Keyword
|
||||
require.NoError(t, Db().First(&fetched, keyword.ID).Error)
|
||||
assert.Equal(t, keyword.Keyword, fetched.Keyword)
|
||||
}
|
||||
|
||||
func TestKeyword_HasID(t *testing.T) {
|
||||
t.Run("Nil", func(t *testing.T) {
|
||||
var keyword *Keyword
|
||||
assert.False(t, keyword.HasID())
|
||||
})
|
||||
t.Run("Unsaved", func(t *testing.T) {
|
||||
keyword := NewKeyword("unsaved")
|
||||
assert.False(t, keyword.HasID())
|
||||
})
|
||||
t.Run("Saved", func(t *testing.T) {
|
||||
keyword := NewKeyword(fmt.Sprintf("keyword-hasid-%d", time.Now().UnixNano()))
|
||||
require.NoError(t, keyword.Save())
|
||||
assert.True(t, keyword.HasID())
|
||||
})
|
||||
}
|
||||
|
||||
func TestFirstOrCreateKeyword_Cached(t *testing.T) {
|
||||
name := fmt.Sprintf("keyword-firstorcreate-%d", time.Now().UnixNano())
|
||||
keyword := NewKeyword(name)
|
||||
result := FirstOrCreateKeyword(keyword)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, keyword.Keyword, result.Keyword)
|
||||
|
||||
// Second call should return cached/existing entity with same ID
|
||||
cached := FirstOrCreateKeyword(NewKeyword(name))
|
||||
require.NotNil(t, cached)
|
||||
assert.Equal(t, result.ID, cached.ID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -201,7 +202,26 @@ func (m *Label) Skip() bool {
|
|||
|
||||
// Update a label property in the database.
|
||||
func (m *Label) Update(attr string, value interface{}) error {
|
||||
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
|
||||
if m == nil {
|
||||
return errors.New("label must not be nil - you may have found a bug")
|
||||
} else if !m.HasID() {
|
||||
return errors.New("label ID must not be empty - you may have found a bug")
|
||||
}
|
||||
|
||||
return UnscopedDb().Model(m).Update(attr, value).Error
|
||||
}
|
||||
|
||||
// Updates multiple columns in the database.
|
||||
func (m *Label) Updates(values interface{}) error {
|
||||
if values == nil {
|
||||
return nil
|
||||
} else if m == nil {
|
||||
return errors.New("label must not be nil - you may have found a bug")
|
||||
} else if !m.HasID() {
|
||||
return errors.New("label ID must not be empty - you may have found a bug")
|
||||
}
|
||||
|
||||
return UnscopedDb().Model(m).Updates(values).Error
|
||||
}
|
||||
|
||||
// FirstOrCreateLabel reuses an existing label matched by slug/custom slug or creates and returns a new one; nil signals lookup/create failure.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ai/classify"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
func TestNewLabel(t *testing.T) {
|
||||
|
|
@ -24,6 +28,40 @@ func TestNewLabel(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestLabel_TableName(t *testing.T) {
|
||||
label := &Label{}
|
||||
assert.Equal(t, "labels", label.TableName())
|
||||
}
|
||||
|
||||
func TestLabel_SaveForm(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
label := createTestLabel(t, "save-form")
|
||||
frm := &form.Label{
|
||||
LabelName: "Sunrise Field",
|
||||
LabelPriority: 7,
|
||||
LabelFavorite: true,
|
||||
LabelDescription: "desc",
|
||||
LabelNotes: "notes",
|
||||
Thumb: "thumb.jpg",
|
||||
ThumbSrc: "manual",
|
||||
}
|
||||
|
||||
require.NoError(t, label.SaveForm(frm))
|
||||
assert.Equal(t, "Sunrise Field", label.LabelName)
|
||||
assert.Equal(t, 7, label.LabelPriority)
|
||||
assert.True(t, label.LabelFavorite)
|
||||
assert.Equal(t, "desc", label.LabelDescription)
|
||||
assert.Equal(t, "notes", label.LabelNotes)
|
||||
assert.Equal(t, "thumb.jpg", label.Thumb)
|
||||
assert.Equal(t, "manual", label.ThumbSrc)
|
||||
})
|
||||
t.Run("InvalidForm", func(t *testing.T) {
|
||||
label := createTestLabel(t, "save-form-invalid")
|
||||
err := label.SaveForm(&form.Label{})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFlushLabelCache(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
FlushLabelCache()
|
||||
|
|
@ -59,6 +97,80 @@ func TestLabel_SetName(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestLabel_HasID(t *testing.T) {
|
||||
t.Run("Nil", func(t *testing.T) {
|
||||
var label *Label
|
||||
assert.False(t, label.HasID())
|
||||
})
|
||||
t.Run("Missing", func(t *testing.T) {
|
||||
label := &Label{ID: 1}
|
||||
assert.False(t, label.HasID())
|
||||
})
|
||||
t.Run("Persisted", func(t *testing.T) {
|
||||
label := createTestLabel(t, "has-id")
|
||||
assert.True(t, label.HasID())
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabel_HasUID(t *testing.T) {
|
||||
t.Run("Nil", func(t *testing.T) {
|
||||
var label *Label
|
||||
assert.False(t, label.HasUID())
|
||||
})
|
||||
t.Run("Invalid", func(t *testing.T) {
|
||||
label := &Label{LabelUID: "invalid"}
|
||||
assert.False(t, label.HasUID())
|
||||
})
|
||||
t.Run("Valid", func(t *testing.T) {
|
||||
uid := rnd.GenerateUID(LabelUID)
|
||||
label := &Label{LabelUID: uid}
|
||||
assert.True(t, label.HasUID())
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabel_Skip(t *testing.T) {
|
||||
t.Run("Nil", func(t *testing.T) {
|
||||
var label *Label
|
||||
assert.True(t, label.Skip())
|
||||
})
|
||||
t.Run("MissingID", func(t *testing.T) {
|
||||
label := &Label{}
|
||||
assert.True(t, label.Skip())
|
||||
})
|
||||
t.Run("Deleted", func(t *testing.T) {
|
||||
label := createTestLabel(t, "skip-deleted")
|
||||
now := time.Now()
|
||||
label.DeletedAt = &now
|
||||
assert.True(t, label.Skip())
|
||||
})
|
||||
t.Run("Active", func(t *testing.T) {
|
||||
label := createTestLabel(t, "skip-active")
|
||||
assert.False(t, label.Skip())
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabel_InvalidName(t *testing.T) {
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
label := &Label{LabelName: ""}
|
||||
assert.True(t, label.InvalidName())
|
||||
})
|
||||
t.Run("Valid", func(t *testing.T) {
|
||||
label := &Label{LabelName: "Valid Name"}
|
||||
assert.False(t, label.InvalidName())
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabel_GetSlug(t *testing.T) {
|
||||
label := &Label{CustomSlug: "custom", LabelSlug: "orig", LabelName: "Name"}
|
||||
assert.Equal(t, "custom", label.GetSlug())
|
||||
|
||||
label.CustomSlug = ""
|
||||
assert.Equal(t, "orig", label.GetSlug())
|
||||
|
||||
label.LabelSlug = ""
|
||||
assert.Equal(t, "name", label.GetSlug())
|
||||
}
|
||||
|
||||
func TestFirstOrCreateLabel(t *testing.T) {
|
||||
label := LabelFixtures.Get("flower")
|
||||
result := FirstOrCreateLabel(&label)
|
||||
|
|
@ -139,6 +251,51 @@ func TestLabel_UpdateClassify(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestLabel_Update(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
label := createTestLabel(t, "update")
|
||||
oldPriority := label.LabelPriority
|
||||
require.NoError(t, label.Update("LabelPriority", oldPriority+5))
|
||||
require.NoError(t, Db().First(label, label.ID).Error)
|
||||
assert.Equal(t, oldPriority+5, label.LabelPriority)
|
||||
})
|
||||
t.Run("NilLabel", func(t *testing.T) {
|
||||
var label *Label
|
||||
err := label.Update("LabelPriority", 1)
|
||||
assert.EqualError(t, err, "label must not be nil - you may have found a bug")
|
||||
})
|
||||
t.Run("MissingID", func(t *testing.T) {
|
||||
label := NewLabel("missing", 0)
|
||||
err := label.Update("LabelPriority", 1)
|
||||
assert.EqualError(t, err, "label ID must not be empty - you may have found a bug")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabel_Updates(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
label := createTestLabel(t, "updates")
|
||||
err := label.Updates(&Label{LabelDescription: "updated", LabelNotes: "notes"})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, Db().First(label, label.ID).Error)
|
||||
assert.Equal(t, "updated", label.LabelDescription)
|
||||
assert.Equal(t, "notes", label.LabelNotes)
|
||||
})
|
||||
t.Run("NilValues", func(t *testing.T) {
|
||||
label := createTestLabel(t, "updates-nil")
|
||||
assert.NoError(t, label.Updates(nil))
|
||||
})
|
||||
t.Run("NilLabel", func(t *testing.T) {
|
||||
var label *Label
|
||||
err := label.Updates(&Label{LabelDescription: "x"})
|
||||
assert.EqualError(t, err, "label must not be nil - you may have found a bug")
|
||||
})
|
||||
t.Run("MissingID", func(t *testing.T) {
|
||||
label := NewLabel("missing", 0)
|
||||
err := label.Updates(&Label{LabelDescription: "x"})
|
||||
assert.EqualError(t, err, "label ID must not be empty - you may have found a bug")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabel_Save(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
label := NewLabel("Unicorn2000", 5)
|
||||
|
|
@ -219,22 +376,15 @@ func TestLabel_Links(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestLabel_Update(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
label := &Label{LabelSlug: "to-be-updated", LabelName: "Update Me Please"}
|
||||
func createTestLabel(t *testing.T, prefix string) *Label {
|
||||
t.Helper()
|
||||
name := fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
||||
label := NewLabel(name, 0)
|
||||
require.NoError(t, label.Save())
|
||||
|
||||
err := label.Save()
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = label.Update("LabelSlug", "my-unique-slug")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "my-unique-slug", label.LabelSlug)
|
||||
t.Cleanup(func() {
|
||||
_ = Db().Unscoped().Delete(label).Error
|
||||
})
|
||||
|
||||
return label
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ func (m *Marker) SetFace(f *Face, dist float64) (updated bool, err error) {
|
|||
if m.SubjUID == f.SubjUID && m.FaceID == f.ID {
|
||||
// Update matching timestamp.
|
||||
m.MatchedAt = TimeStamp()
|
||||
return false, m.Updates(Values{"MatchedAt": m.MatchedAt})
|
||||
return false, m.Updates(Values{"matched_at": m.MatchedAt})
|
||||
}
|
||||
|
||||
// Remember current values for comparison.
|
||||
|
|
@ -302,7 +302,8 @@ func (m *Marker) SetFace(f *Face, dist float64) (updated bool, err error) {
|
|||
// Update matching timestamp.
|
||||
m.MatchedAt = TimeStamp()
|
||||
|
||||
if err := m.Updates(Values{"FaceID": m.FaceID, "FaceDist": m.FaceDist, "SubjUID": m.SubjUID, "SubjSrc": m.SubjSrc, "MarkerReview": false, "MatchedAt": m.MatchedAt}); err != nil {
|
||||
if err = m.Updates(Values{"face_id": m.FaceID, "face_dist": m.FaceDist, "subj_uid": m.SubjUID,
|
||||
"subj_src": m.SubjSrc, "marker_review": false, "matched_at": m.MatchedAt}); err != nil {
|
||||
return false, err
|
||||
} else if !updated {
|
||||
return false, nil
|
||||
|
|
@ -472,20 +473,25 @@ func (m *Marker) ClearSubject(src string) error {
|
|||
}()
|
||||
|
||||
// Update index & resolve collisions.
|
||||
if err := m.Updates(Values{"MarkerName": "", "FaceID": "", "FaceDist": -1.0, "SubjUID": "", "SubjSrc": src}); err != nil {
|
||||
if err := m.Updates(Values{"marker_name": "", "face_id": "", "face_dist": -1.0, "subj_uid": "", "subj_src": src}); err != nil {
|
||||
return err
|
||||
} else if m.face == nil {
|
||||
m.subject = nil
|
||||
return nil
|
||||
} else if resolved, err := m.face.ResolveCollision(m.Embeddings()); err != nil {
|
||||
return err
|
||||
} else if resolved, colErr := m.face.ResolveCollision(m.Embeddings()); colErr != nil {
|
||||
return colErr
|
||||
} else if resolved {
|
||||
log.Debugf("faces: marker %s resolved ambiguous subjects for face %s", clean.Log(m.MarkerUID), clean.Log(m.face.ID))
|
||||
}
|
||||
|
||||
// Clear references.
|
||||
m.MarkerName = ""
|
||||
m.face = nil
|
||||
m.FaceID = ""
|
||||
m.FaceDist = -1.0
|
||||
m.subject = nil
|
||||
m.SubjUID = ""
|
||||
m.SubjSrc = src
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -547,14 +553,15 @@ func (m *Marker) ClearFace() (updated bool, err error) {
|
|||
// Remove face references.
|
||||
m.face = nil
|
||||
m.FaceID = ""
|
||||
m.FaceDist = -1.0
|
||||
m.MatchedAt = TimeStamp()
|
||||
|
||||
// Remove subject if set automatically.
|
||||
if m.SubjSrc == SrcAuto {
|
||||
m.SubjUID = ""
|
||||
err = m.Updates(Values{"FaceID": "", "FaceDist": -1.0, "SubjUID": "", "MatchedAt": m.MatchedAt})
|
||||
err = m.Updates(Values{"face_id": m.FaceID, "face_dist": m.FaceDist, "subj_uid": m.SubjUID, "matched_at": m.MatchedAt})
|
||||
} else {
|
||||
err = m.Updates(Values{"FaceID": "", "FaceDist": -1.0, "MatchedAt": m.MatchedAt})
|
||||
err = m.Updates(Values{"face_id": m.FaceID, "face_dist": -1.0, "matched_at": m.MatchedAt})
|
||||
}
|
||||
|
||||
return updated, m.RefreshPhotos()
|
||||
|
|
@ -585,7 +592,7 @@ func (m *Marker) RefreshPhotos() error {
|
|||
// Matched updates the match timestamp.
|
||||
func (m *Marker) Matched() error {
|
||||
m.MatchedAt = TimeStamp()
|
||||
return UnscopedDb().Model(m).UpdateColumns(Values{"MatchedAt": m.MatchedAt}).Error
|
||||
return UnscopedDb().Model(m).UpdateColumns(Values{"matched_at": m.MatchedAt}).Error
|
||||
}
|
||||
|
||||
// Top returns the top Y coordinate as float64.
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ func TestMarker_Updates(t *testing.T) {
|
|||
assert.Equal(t, SrcImage, m.MarkerSrc)
|
||||
assert.Equal(t, MarkerLabel, m.MarkerType)
|
||||
|
||||
if err := m.Updates(Marker{MarkerSrc: SrcMeta}); err != nil {
|
||||
if err = m.Updates(Marker{MarkerSrc: SrcMeta}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ func (m *Migration) Fail(err error, db *gorm.DB) {
|
|||
m.Error = err.Error()
|
||||
}
|
||||
|
||||
db.Model(m).Updates(Map{"FinishedAt": m.FinishedAt, "Error": m.Error})
|
||||
db.Model(m).Updates(Map{"finished_at": m.FinishedAt, "error": m.Error})
|
||||
}
|
||||
|
||||
// Finish updates the FinishedAt timestamp and removes the error message when the migration was successful.
|
||||
|
|
@ -111,7 +111,7 @@ func (m *Migration) Finish(db *gorm.DB) error {
|
|||
finished := time.Now().UTC().Truncate(time.Second)
|
||||
m.FinishedAt = &finished
|
||||
m.Error = ""
|
||||
return db.Model(m).Updates(Map{"FinishedAt": m.FinishedAt, "Error": m.Error}).Error
|
||||
return db.Model(m).Updates(Map{"finished_at": m.FinishedAt, "error": m.Error}).Error
|
||||
}
|
||||
|
||||
// Execute runs the migration.
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ func (m *Version) Migrated(db *gorm.DB) error {
|
|||
m.MigratedAt = &timeStamp
|
||||
m.Error = ""
|
||||
|
||||
return db.Model(m).Updates(Map{"MigratedAt": m.MigratedAt, "Error": m.Error}).Error
|
||||
return db.Model(m).Updates(Map{"migrated_at": m.MigratedAt, "error": m.Error}).Error
|
||||
}
|
||||
|
||||
// NewVersion creates a Version entity from a model name and a make name.
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@ func (m *Passcode) Valid(code string) (valid bool, recovery bool, err error) {
|
|||
// Set verified timestamp if nil.
|
||||
if valid && m.VerifiedAt == nil {
|
||||
m.VerifiedAt = TimeStamp()
|
||||
err = m.Updates(Values{"VerifiedAt": m.VerifiedAt})
|
||||
err = m.Updates(Values{"verified_at": m.VerifiedAt})
|
||||
}
|
||||
|
||||
// Return result.
|
||||
|
|
@ -304,7 +304,7 @@ func (m *Passcode) Activate() (err error) {
|
|||
return authn.ErrPasscodeAlreadyActivated
|
||||
} else {
|
||||
m.ActivatedAt = TimeStamp()
|
||||
err = m.Updates(Values{"ActivatedAt": m.ActivatedAt})
|
||||
err = m.Updates(Values{"activated_at": m.ActivatedAt})
|
||||
}
|
||||
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -380,6 +380,10 @@ func (m *Photo) HasUID() bool {
|
|||
|
||||
// GetUID returns the unique entity id.
|
||||
func (m *Photo) GetUID() string {
|
||||
if m == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
|
||||
return m.PhotoUID
|
||||
}
|
||||
|
||||
|
|
@ -481,13 +485,19 @@ func (m *Photo) SaveLabels() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// LabelKeywords converts the loaded photo labels (and their categories)
|
||||
// into the keyword tokens that should be indexable for full‑text search.
|
||||
// LabelKeywords converts the photo labels (and their categories) into
|
||||
// keyword tokens that should be indexable for full‑text search. When the
|
||||
// relation has not been preloaded yet, it fetches the labels transparently
|
||||
// so callers always receive the same output.
|
||||
func (m *Photo) LabelKeywords() (result []string) {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if m.Labels == nil {
|
||||
m.PreloadLabels()
|
||||
}
|
||||
|
||||
for _, l := range m.Labels {
|
||||
if l.Label == nil {
|
||||
continue
|
||||
|
|
@ -770,12 +780,18 @@ func (m *Photo) PreloadAlbums() {
|
|||
Log("photo", "preload albums", q.Scan(&m.Albums).Error)
|
||||
}
|
||||
|
||||
// PreloadLabels loads labels related to the photo from the database.
|
||||
// PreloadLabels loads labels related to the photo from the database. It is a
|
||||
// no-op when the Photo pointer is nil or the record has not been persisted yet
|
||||
// so call sites can invoke it defensively before reading `m.Labels`.
|
||||
func (m *Photo) PreloadLabels() {
|
||||
if err := Db().Model(PhotoLabel{}).Preload("Label").Where("photo_id = ?", m.ID).
|
||||
Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC").Find(&m.Labels).Error; err != nil {
|
||||
log.Warnf("photo: failed to fetch labels (%s)", err)
|
||||
if m == nil {
|
||||
return
|
||||
} else if !m.HasID() {
|
||||
return
|
||||
}
|
||||
|
||||
Log("photo", "preload labels", Db().Model(PhotoLabel{}).Preload("Label").Where("photo_id = ?", m.ID).
|
||||
Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC").Find(&m.Labels).Error)
|
||||
}
|
||||
|
||||
// PreloadMany loads the primary supporting associations (files, keywords, albums).
|
||||
|
|
@ -1125,7 +1141,10 @@ func (m *Photo) Delete(permanently bool) (files Files, err error) {
|
|||
}
|
||||
}
|
||||
|
||||
return files, m.Updates(Values{"DeletedAt": Now(), "PhotoQuality": -1})
|
||||
m.DeletedAt = TimeStamp()
|
||||
m.PhotoQuality = -1
|
||||
|
||||
return files, m.Updates(Values{"deleted_at": *m.DeletedAt, "photo_quality": m.PhotoQuality})
|
||||
}
|
||||
|
||||
// DeletePermanently permanently removes a photo from the index.
|
||||
|
|
@ -1193,7 +1212,7 @@ func (m *Photo) SetFavorite(favorite bool) error {
|
|||
m.PhotoFavorite = favorite
|
||||
m.PhotoQuality = m.QualityScore()
|
||||
|
||||
if err := m.Updates(Values{"PhotoFavorite": m.PhotoFavorite, "PhotoQuality": m.PhotoQuality}); err != nil {
|
||||
if err := m.Updates(Values{"photo_favorite": m.PhotoFavorite, "photo_quality": m.PhotoQuality}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -1217,7 +1236,7 @@ func (m *Photo) SetFavorite(favorite bool) error {
|
|||
func (m *Photo) SetStack(stack int8) {
|
||||
if m.PhotoStack != stack {
|
||||
m.PhotoStack = stack
|
||||
Log("photo", "update stack flag", m.Update("PhotoStack", m.PhotoStack))
|
||||
Log("photo", "update stack flag", m.Update("photo_stack", m.PhotoStack))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
// PhotoKeyword represents the many-to-many relation between Photo and Keyword.
|
||||
type PhotoKeyword struct {
|
||||
PhotoID uint `gorm:"primary_key;auto_increment:false"`
|
||||
|
|
@ -26,16 +30,55 @@ func (m *PhotoKeyword) Create() error {
|
|||
return Db().Create(m).Error
|
||||
}
|
||||
|
||||
// AfterCreate flushes the keyword cache once a relation has been persisted.
|
||||
func (m *PhotoKeyword) AfterCreate(scope *gorm.Scope) error {
|
||||
FlushCachedPhotoKeyword(m)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AfterUpdate flushes the keyword cache after a relation change.
|
||||
func (m *PhotoKeyword) AfterUpdate(tx *gorm.DB) (err error) {
|
||||
FlushCachedPhotoKeyword(m)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete removes the keyword reference and clears the cache.
|
||||
func (m *PhotoKeyword) Delete() error {
|
||||
FlushCachedPhotoKeyword(m)
|
||||
return Db().Delete(m).Error
|
||||
}
|
||||
|
||||
// AfterDelete flushes the keyword cache when the photo-keyword relation is removed.
|
||||
func (m *PhotoKeyword) AfterDelete(tx *gorm.DB) (err error) {
|
||||
FlushCachedPhotoKeyword(m)
|
||||
return
|
||||
}
|
||||
|
||||
// HasID reports whether both sides of the relation have identifiers assigned,
|
||||
// meaning the join row exists (or is ready to be cached) in the database.
|
||||
func (m *PhotoKeyword) HasID() bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
return m.PhotoID > 0 && m.KeywordID > 0
|
||||
}
|
||||
|
||||
// CacheKey returns a string key for caching the entity.
|
||||
func (m *PhotoKeyword) CacheKey() string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return photoKeywordCacheKey(m.PhotoID, m.KeywordID)
|
||||
}
|
||||
|
||||
// FirstOrCreatePhotoKeyword returns the existing row, inserts a new row or nil in case of errors.
|
||||
func FirstOrCreatePhotoKeyword(m *PhotoKeyword) *PhotoKeyword {
|
||||
result := PhotoKeyword{}
|
||||
|
||||
if err := Db().Where("photo_id = ? AND keyword_id = ?", m.PhotoID, m.KeywordID).First(&result).Error; err == nil {
|
||||
return &result
|
||||
if result, err := FindPhotoKeyword(m.PhotoID, m.KeywordID, true); err == nil {
|
||||
return result
|
||||
} else if createErr := m.Create(); createErr == nil {
|
||||
return m
|
||||
} else if err = Db().Where("photo_id = ? AND keyword_id = ?", m.PhotoID, m.KeywordID).First(&result).Error; err == nil {
|
||||
return &result
|
||||
} else if result, err = FindPhotoKeyword(m.PhotoID, m.KeywordID, false); err == nil {
|
||||
return result
|
||||
} else {
|
||||
log.Errorf("photo-keyword: %s (find or create)", createErr)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewPhotoKeyword(t *testing.T) {
|
||||
|
|
@ -37,3 +40,21 @@ func TestFirstOrCreatePhotoKeyword(t *testing.T) {
|
|||
t.Errorf("KeywordID should be the same: %d %d", result.KeywordID, model.KeywordID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhotoKeyword_Delete(t *testing.T) {
|
||||
FlushPhotoKeywordCache()
|
||||
photo := &Photo{}
|
||||
require.NoError(t, Db().First(photo).Error)
|
||||
keyword := NewKeyword(fmt.Sprintf("photo-keyword-delete-%d", time.Now().UnixNano()))
|
||||
require.NoError(t, keyword.Save())
|
||||
|
||||
relation := NewPhotoKeyword(photo.ID, keyword.ID)
|
||||
require.NoError(t, relation.Create())
|
||||
|
||||
photoKeywordCache.SetDefault(relation.CacheKey(), *relation)
|
||||
|
||||
require.NoError(t, relation.Delete())
|
||||
|
||||
_, found := photoKeywordCache.Get(relation.CacheKey())
|
||||
assert.False(t, found)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,12 +73,6 @@ func (m *PhotoLabel) Update(attr string, value interface{}) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// AfterUpdate flushes the label cache after a relation change.
|
||||
func (m *PhotoLabel) AfterUpdate(tx *gorm.DB) (err error) {
|
||||
FlushCachedPhotoLabel(m)
|
||||
return
|
||||
}
|
||||
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *PhotoLabel) Save() error {
|
||||
if m.Photo != nil {
|
||||
|
|
@ -106,6 +100,12 @@ func (m *PhotoLabel) AfterCreate(scope *gorm.Scope) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// AfterUpdate flushes the label cache after a relation change.
|
||||
func (m *PhotoLabel) AfterUpdate(tx *gorm.DB) (err error) {
|
||||
FlushCachedPhotoLabel(m)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete removes the label reference and clears the cache.
|
||||
func (m *PhotoLabel) Delete() error {
|
||||
FlushCachedPhotoLabel(m)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ai/classify"
|
||||
)
|
||||
|
|
@ -85,15 +88,104 @@ func TestPhotoLabel_Save(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestPhotoLabel_Update(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
photoLabel := PhotoLabel{LabelID: 555, PhotoID: 888}
|
||||
assert.Equal(t, uint(0x22b), photoLabel.LabelID)
|
||||
t.Run("FlushesCache", func(t *testing.T) {
|
||||
FlushPhotoLabelCache()
|
||||
relation := createTestPhotoLabel(t)
|
||||
|
||||
err := photoLabel.Update("LabelID", 8)
|
||||
photoLabelCache.SetDefault(relation.CacheKey(), *relation)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, uint(0x8), photoLabel.LabelID)
|
||||
require.NoError(t, relation.Update("uncertainty", relation.Uncertainty+1))
|
||||
|
||||
_, found := photoLabelCache.Get(relation.CacheKey())
|
||||
assert.False(t, found)
|
||||
})
|
||||
|
||||
t.Run("NilPhotoLabel", func(t *testing.T) {
|
||||
var label *PhotoLabel
|
||||
err := label.Update("uncertainty", 0)
|
||||
assert.EqualError(t, err, "photo label must not be nil - you may have found a bug")
|
||||
})
|
||||
|
||||
t.Run("MissingID", func(t *testing.T) {
|
||||
label := &PhotoLabel{PhotoID: 0, LabelID: 1}
|
||||
err := label.Update("uncertainty", 0)
|
||||
assert.EqualError(t, err, "photo label ID must not be empty - you may have found a bug")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPhotoLabel_Updates(t *testing.T) {
|
||||
t.Run("FlushesCache", func(t *testing.T) {
|
||||
FlushPhotoLabelCache()
|
||||
relation := createTestPhotoLabel(t)
|
||||
|
||||
photoLabelCache.SetDefault(relation.CacheKey(), *relation)
|
||||
|
||||
require.NoError(t, relation.Updates(&PhotoLabel{Uncertainty: relation.Uncertainty + 1}))
|
||||
|
||||
_, found := photoLabelCache.Get(relation.CacheKey())
|
||||
assert.False(t, found)
|
||||
})
|
||||
|
||||
t.Run("NilPhotoLabel", func(t *testing.T) {
|
||||
var label *PhotoLabel
|
||||
err := label.Updates(&PhotoLabel{Uncertainty: 0})
|
||||
assert.EqualError(t, err, "photo label must not be nil - you may have found a bug")
|
||||
})
|
||||
|
||||
t.Run("MissingID", func(t *testing.T) {
|
||||
label := &PhotoLabel{PhotoID: 0, LabelID: 1}
|
||||
err := label.Updates(&PhotoLabel{Uncertainty: 0})
|
||||
assert.EqualError(t, err, "photo label ID must not be empty - you may have found a bug")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPhotoLabel_Delete(t *testing.T) {
|
||||
FlushPhotoLabelCache()
|
||||
relation := createTestPhotoLabel(t)
|
||||
photoLabelCache.SetDefault(relation.CacheKey(), *relation)
|
||||
|
||||
require.NoError(t, relation.Delete())
|
||||
|
||||
_, found := photoLabelCache.Get(relation.CacheKey())
|
||||
assert.False(t, found)
|
||||
}
|
||||
|
||||
func TestPhotoLabel_HasID(t *testing.T) {
|
||||
t.Run("Nil", func(t *testing.T) {
|
||||
var label *PhotoLabel
|
||||
assert.False(t, label.HasID())
|
||||
})
|
||||
|
||||
t.Run("Missing", func(t *testing.T) {
|
||||
label := &PhotoLabel{PhotoID: 1}
|
||||
assert.False(t, label.HasID())
|
||||
})
|
||||
|
||||
t.Run("Complete", func(t *testing.T) {
|
||||
label := &PhotoLabel{PhotoID: 1, LabelID: 2}
|
||||
assert.True(t, label.HasID())
|
||||
})
|
||||
}
|
||||
|
||||
func TestPhotoLabel_CacheKey(t *testing.T) {
|
||||
label := &PhotoLabel{PhotoID: 1, LabelID: 2}
|
||||
assert.Equal(t, "1-2", label.CacheKey())
|
||||
}
|
||||
|
||||
func createTestPhotoLabel(t *testing.T) *PhotoLabel {
|
||||
t.Helper()
|
||||
photo := &Photo{}
|
||||
require.NoError(t, Db().First(photo).Error)
|
||||
label := NewLabel(fmt.Sprintf("photo-label-test-%d", time.Now().UnixNano()), 0)
|
||||
require.NoError(t, label.Save())
|
||||
|
||||
relation := NewPhotoLabel(photo.ID, label.ID, 0, SrcManual)
|
||||
require.NoError(t, relation.Create())
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = Db().Where("photo_id = ? AND label_id = ?", relation.PhotoID, relation.LabelID).Delete(&PhotoLabel{}).Error
|
||||
_ = Db().Delete(label).Error
|
||||
})
|
||||
|
||||
return relation
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,6 +164,18 @@ func TestPhoto_LabelKeywords(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestPhoto_GetUID(t *testing.T) {
|
||||
t.Run("ReturnsPhotoUID", func(t *testing.T) {
|
||||
uid := rnd.GenerateUID(PhotoUID)
|
||||
photo := &Photo{PhotoUID: uid}
|
||||
assert.Equal(t, uid, photo.GetUID())
|
||||
})
|
||||
t.Run("NilPhoto", func(t *testing.T) {
|
||||
var photo *Photo
|
||||
assert.Equal(t, "<nil>", photo.GetUID())
|
||||
})
|
||||
}
|
||||
|
||||
func photoKeywordWords(t *testing.T, photoID uint) []string {
|
||||
t.Helper()
|
||||
|
||||
|
|
@ -588,7 +600,7 @@ func TestPhoto_AddLabels(t *testing.T) {
|
|||
label := LabelFixtures.Get(labelName)
|
||||
assert.NoError(t, UnscopedDb().Model(&PhotoLabel{}).
|
||||
Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).
|
||||
UpdateColumns(Values{"Uncertainty": uncertainty, "LabelSrc": src}).Error)
|
||||
UpdateColumns(Values{"uncertainty": uncertainty, "label_src": src}).Error)
|
||||
}
|
||||
|
||||
t.Run("Add", func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ func (m *Photo) SaveSidecarYaml(originalsPath, sidecarPath string) error {
|
|||
log.Warnf("photo: %s (%s %s)", err, action, clean.Log(relName))
|
||||
return err
|
||||
} else {
|
||||
log.Infof("photo: %sd sidecar file %s", action, clean.Log(relName))
|
||||
log.Debugf("photo: %sd sidecar file %s", action, clean.Log(relName))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ func ResetFaceMergeRetry(subjUID string) (int, error) {
|
|||
stmt = stmt.Where("subj_uid = ?", subjUID)
|
||||
}
|
||||
|
||||
res := stmt.UpdateColumns(entity.Values{"MergeRetry": 0, "MergeNotes": ""})
|
||||
res := stmt.UpdateColumns(entity.Values{"merge_retry": 0, "merge_notes": ""})
|
||||
|
||||
if res.Error != nil {
|
||||
return 0, res.Error
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ func RemoveOrphanSubjects() (removed int64, err error) {
|
|||
func CreateMarkerSubjects() (affected int64, err error) {
|
||||
var markers entity.Markers
|
||||
|
||||
if err := Db().
|
||||
if err = Db().
|
||||
Where("subj_uid = '' AND marker_name <> '' AND subj_src <> ?", entity.SrcAuto).
|
||||
Where("marker_invalid = 0 AND marker_type = ?", entity.MarkerFace).
|
||||
Order("marker_name").
|
||||
|
|
@ -103,14 +103,16 @@ func CreateMarkerSubjects() (affected int64, err error) {
|
|||
}
|
||||
|
||||
name = m.MarkerName
|
||||
m.SubjUID = subj.SubjUID
|
||||
m.MarkerReview = false
|
||||
|
||||
if err := m.Updates(entity.Values{"SubjUID": subj.SubjUID, "MarkerReview": false}); err != nil {
|
||||
if err = m.Updates(entity.Values{"subj_uid": m.SubjUID, "marker_review": m.MarkerReview}); err != nil {
|
||||
return affected, err
|
||||
}
|
||||
|
||||
if m.FaceID == "" {
|
||||
continue
|
||||
} else if err := Db().Model(&entity.Face{}).Where("id = ? AND subj_uid = ''", m.FaceID).Update("SubjUID", subj.SubjUID).Error; err != nil {
|
||||
} else if err = Db().Model(&entity.Face{}).Where("id = ? AND subj_uid = ''", m.FaceID).Update("subj_uid", m.SubjUID).Error; err != nil {
|
||||
return affected, err
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -444,7 +444,7 @@ func (m *Subject) UpdateName(name string) (*Subject, error) {
|
|||
// Update subject record.
|
||||
if err := m.SetName(name); err != nil {
|
||||
return m, err
|
||||
} else if err = m.Updates(Values{"SubjName": m.SubjName, "SubjSlug": m.SubjSlug}); err != nil {
|
||||
} else if err = m.Updates(Values{"subj_name": m.SubjName, "subj_slug": m.SubjSlug}); err != nil {
|
||||
return m, err
|
||||
} else {
|
||||
SubjNames.Set(m.SubjUID, m.SubjName)
|
||||
|
|
|
|||
|
|
@ -75,10 +75,10 @@ func (w *Faces) Cluster(opt FacesOptions) (added entity.Faces, err error) {
|
|||
log.Errorf("faces: face must not be nil - you may have found a bug")
|
||||
} else if f.SkipMatching() {
|
||||
log.Infof("faces: skipped cluster %s, embedding not distinct enough", f.ID)
|
||||
} else if err := f.Create(); err == nil {
|
||||
} else if err = f.Create(); err == nil {
|
||||
added = append(added, *f)
|
||||
log.Debugf("faces: added cluster %s based on %s, radius %f", f.ID, english.Plural(f.Samples, "sample", "samples"), f.SampleRadius)
|
||||
} else if err := f.Updates(entity.Values{"UpdatedAt": entity.Now()}); err != nil {
|
||||
} else if err = f.Updates(entity.Values{"updated_at": entity.Now()}); err != nil {
|
||||
log.Errorf("faces: %s", err)
|
||||
} else {
|
||||
log.Debugf("faces: updated cluster %s", f.ID)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue