Database: Improve config and SQL queries

This commit is contained in:
Michael Mayer 2020-12-15 20:14:06 +01:00
parent 40966c2add
commit 28880e682d
20 changed files with 107 additions and 61 deletions

View file

@ -73,7 +73,7 @@ func backupAction(ctx *cli.Context) error {
var cmd *exec.Cmd
switch conf.DatabaseDriver() {
case config.MySQL:
case config.MySQL, config.MariaDB:
cmd = exec.Command(
conf.MysqldumpBin(),
"-h", conf.DatabaseHost(),

View file

@ -101,7 +101,7 @@ func restoreAction(ctx *cli.Context) error {
var cmd *exec.Cmd
switch conf.DatabaseDriver() {
case config.MySQL:
case config.MySQL, config.MariaDB:
cmd = exec.Command(
conf.MysqlBin(),
"-h", conf.DatabaseHost(),

View file

@ -232,77 +232,93 @@ func (c *Config) UserConfig() ClientConfig {
Server: NewRuntimeInfo(),
}
c.Db().Table("photos").
c.Db().
Table("photos").
Select("photo_uid, cell_id, photo_lat, photo_lng, taken_at").
Where("deleted_at IS NULL AND photo_lat != 0 AND photo_lng != 0").
Order("taken_at DESC").
Limit(1).Offset(0).
Take(&result.Pos)
c.Db().Table("cameras").
c.Db().
Table("cameras").
Where("camera_slug <> 'zz' AND camera_slug <> ''").
Select("COUNT(*) AS cameras").
Take(&result.Count)
c.Db().Table("lenses").
c.Db().
Table("lenses").
Where("lens_slug <> 'zz' AND lens_slug <> ''").
Select("COUNT(*) AS lenses").
Take(&result.Count)
c.Db().Table("photos").
c.Db().
Table("photos").
Select("SUM(photo_type = 'video' AND photo_quality >= 0 AND photo_private = 0) AS videos, SUM(photo_type IN ('image','raw','live') AND photo_quality < 3 AND photo_quality >= 0 AND photo_private = 0) AS review, SUM(photo_quality = -1) AS hidden, SUM(photo_type IN ('image','raw','live') AND photo_private = 0 AND photo_quality >= 0) AS photos, SUM(photo_favorite = 1 AND photo_private = 0 AND photo_quality >= 0) AS favorites, SUM(photo_private = 1 AND photo_quality >= 0) AS private").
Where("photos.id NOT IN (SELECT photo_id FROM files WHERE file_primary = 1 AND (file_missing = 1 OR file_error <> ''))").
Where("deleted_at IS NULL").
Take(&result.Count)
c.Db().Table("labels").
c.Db().
Table("labels").
Select("MAX(photo_count) as label_max_photos, COUNT(*) AS labels").
Where("photo_count > 0").
Where("deleted_at IS NULL").
Where("(label_priority >= 0 OR label_favorite = 1)").
Take(&result.Count)
c.Db().Table("albums").
c.Db().
Table("albums").
Select("SUM(album_type = ?) AS albums, SUM(album_type = ?) AS moments, SUM(album_type = ?) AS months, SUM(album_type = ?) AS states, SUM(album_type = ?) AS folders", entity.AlbumDefault, entity.AlbumMoment, entity.AlbumMonth, entity.AlbumState, entity.AlbumFolder).
Where("deleted_at IS NULL AND (albums.album_type <> 'folder' OR albums.album_path IN (SELECT photos.photo_path FROM photos WHERE photos.deleted_at IS NULL))").
Take(&result.Count)
c.Db().Table("files").
c.Db().
Table("files").
Select("COUNT(*) AS files").
Where("file_missing = 0").
Where("deleted_at IS NULL").
Take(&result.Count)
c.Db().Table("countries").
c.Db().
Table("countries").
Select("(COUNT(*) - 1) AS countries").
Take(&result.Count)
c.Db().Table("places").
c.Db().
Table("places").
Select("SUM(photo_count > 0) AS places").
Where("id != 'zz'").
Take(&result.Count)
c.Db().Order("country_slug").
c.Db().
Order("country_slug").
Find(&result.Countries)
c.Db().Where("deleted_at IS NULL").
c.Db().
Where("id IN (SELECT photos.camera_id FROM photos WHERE photos.photo_quality >= 0 OR photos.deleted_at IS NULL)").
Where("deleted_at IS NULL").
Limit(10000).Order("camera_slug").
Find(&result.Cameras)
c.Db().Where("deleted_at IS NULL").
c.Db().
Where("deleted_at IS NULL").
Limit(10000).Order("lens_slug").
Find(&result.Lenses)
c.Db().Where("deleted_at IS NULL AND album_favorite = 1").
c.Db().
Where("deleted_at IS NULL AND album_favorite = 1").
Limit(20).Order("album_title").
Find(&result.Albums)
c.Db().Table("photos").
Where("photo_year > 0").
c.Db().
Table("photos").
Where("photo_year > 0 AND (photos.photo_quality >= 0 OR photos.deleted_at IS NULL)").
Order("photo_year DESC").
Pluck("DISTINCT photo_year", &result.Years)
c.Db().Table("categories").
c.Db().
Table("categories").
Select("l.label_uid, l.custom_slug, l.label_name").
Joins("JOIN labels l ON categories.category_id = l.id").
Where("l.deleted_at IS NULL").
@ -311,7 +327,8 @@ func (c *Config) UserConfig() ClientConfig {
Limit(1000).Offset(0).
Scan(&result.Categories)
c.Db().Table("albums").
c.Db().
Table("albums").
Select("album_category").
Where("deleted_at IS NULL AND album_category <> ''").
Group("album_category").

View file

@ -27,7 +27,7 @@ var dsnPattern = regexp.MustCompile(
// DatabaseDriver returns the database driver name.
func (c *Config) DatabaseDriver() string {
switch strings.ToLower(c.params.DatabaseDriver) {
case MySQL, "mariadb":
case MySQL, MariaDB:
c.params.DatabaseDriver = MySQL
case SQLite, "sqlite", "sqllite", "test", "file", "":
c.params.DatabaseDriver = SQLite
@ -48,14 +48,23 @@ func (c *Config) DatabaseDriver() string {
func (c *Config) DatabaseDsn() string {
if c.params.DatabaseDsn == "" {
switch c.DatabaseDriver() {
case MySQL:
case MySQL, MariaDB:
return fmt.Sprintf(
"%s:%s@tcp(%s)/%s?charset=utf8mb4,utf8&parseTime=true",
"%s:%s@tcp(%s)/%s?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true",
c.DatabaseUser(),
c.DatabasePassword(),
c.DatabaseServer(),
c.DatabaseName(),
)
case Postgres:
return fmt.Sprintf(
"user=%s password=%s dbname=%s host=%s port=%d sslmode=disable TimeZone=UTC",
c.DatabaseUser(),
c.DatabasePassword(),
c.DatabaseName(),
c.DatabaseHost(),
c.DatabasePort(),
)
case SQLite:
return filepath.Join(c.StoragePath(), "index.db")
default:

View file

@ -16,8 +16,9 @@ import (
// Database drivers (sql dialects).
const (
MySQL = "mysql"
MariaDB = "mariadb"
SQLite = "sqlite3"
// Postgres = "postgres" // TODO: Requires GORM 2.0 for generic column data types
Postgres = "postgres" // TODO: Requires GORM 2.0 for generic column data types
)
// Params provides a struct in which application configuration is stored.
@ -32,6 +33,7 @@ type Params struct {
Name string
Version string
Copyright string
ConfigFile string
SiteUrl string `yaml:"site-url" flag:"site-url"`
SitePreview string `yaml:"site-preview" flag:"site-preview"`
SiteTitle string `yaml:"site-title" flag:"site-title"`
@ -53,7 +55,6 @@ type Params struct {
ImportPath string `yaml:"import-path" flag:"import-path"`
OriginalsPath string `yaml:"originals-path" flag:"originals-path"`
OriginalsLimit int64 `yaml:"originals-limit" flag:"originals-limit"`
ConfigFile string
SettingsPath string `yaml:"settings-path" flag:"settings-path"`
SettingsHidden bool `yaml:"settings-hidden" flag:"settings-hidden"`
TempPath string `yaml:"temp-path" flag:"temp-path"`

View file

@ -36,10 +36,10 @@ type Account struct {
AccShare bool
AccSync bool
RetryLimit int
SharePath string `gorm:"type:VARBINARY(255);"`
SharePath string `gorm:"type:VARBINARY(500);"`
ShareSize string `gorm:"type:VARBINARY(16);"`
ShareExpires int
SyncPath string `gorm:"type:VARBINARY(255);"`
SyncPath string `gorm:"type:VARBINARY(500);"`
SyncStatus string `gorm:"type:VARBINARY(16);"`
SyncInterval int
SyncDate sql.NullTime `deepcopier:"skip"`

View file

@ -32,7 +32,7 @@ type Album struct {
CoverUID string `gorm:"type:VARBINARY(42);" json:"CoverUID" yaml:"CoverUID,omitempty"`
FolderUID string `gorm:"type:VARBINARY(42);index;" json:"FolderUID" yaml:"FolderUID,omitempty"`
AlbumSlug string `gorm:"type:VARBINARY(255);index;" json:"Slug" yaml:"Slug"`
AlbumPath string `gorm:"type:VARBINARY(768);index;" json:"Path" yaml:"-"`
AlbumPath string `gorm:"type:VARBINARY(500);index;" json:"Path" yaml:"-"`
AlbumType string `gorm:"type:VARBINARY(8);default:'album';" json:"Type" yaml:"Type,omitempty"`
AlbumTitle string `gorm:"type:VARCHAR(255);" json:"Title" yaml:"Title"`
AlbumLocation string `gorm:"type:VARCHAR(255);" json:"Location" yaml:"Location,omitempty"`

View file

@ -11,7 +11,7 @@ type DuplicatesMap map[string]Duplicate
// Duplicate represents an exact file duplicate.
type Duplicate struct {
FileName string `gorm:"type:VARBINARY(768);primary_key;" json:"Name" yaml:"Name"`
FileName string `gorm:"type:VARBINARY(755);primary_key;" json:"Name" yaml:"Name"`
FileRoot string `gorm:"type:VARBINARY(16);primary_key;default:'/';" json:"Root" yaml:"Root,omitempty"`
FileHash string `gorm:"type:VARBINARY(128);default:'';index" json:"Hash" yaml:"Hash,omitempty"`
FileSize int64 `json:"Size" yaml:"Size,omitempty"`

View file

@ -24,9 +24,9 @@ type File struct {
PhotoUID string `gorm:"type:VARBINARY(42);index;" json:"PhotoUID" yaml:"PhotoUID"`
InstanceID string `gorm:"type:VARBINARY(42);index;" json:"InstanceID,omitempty" yaml:"InstanceID,omitempty"`
FileUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
FileName string `gorm:"type:VARBINARY(768);unique_index:idx_files_name_root;" json:"Name" yaml:"Name"`
FileName string `gorm:"type:VARBINARY(755);unique_index:idx_files_name_root;" json:"Name" yaml:"Name"`
FileRoot string `gorm:"type:VARBINARY(16);default:'/';unique_index:idx_files_name_root;" json:"Root" yaml:"Root,omitempty"`
OriginalName string `gorm:"type:VARBINARY(768);" json:"OriginalName" yaml:"OriginalName,omitempty"`
OriginalName string `gorm:"type:VARBINARY(755);" json:"OriginalName" yaml:"OriginalName,omitempty"`
FileHash string `gorm:"type:VARBINARY(128);index" json:"Hash" yaml:"Hash,omitempty"`
FileSize int64 `json:"Size" yaml:"Size,omitempty"`
FileCodec string `gorm:"type:VARBINARY(32)" json:"Codec" yaml:"Codec,omitempty"`
@ -48,7 +48,7 @@ type File struct {
FileLuminance string `gorm:"type:VARBINARY(9);" json:"Luminance" yaml:"Luminance,omitempty"`
FileDiff uint32 `json:"Diff" yaml:"Diff,omitempty"`
FileChroma uint8 `json:"Chroma" yaml:"Chroma,omitempty"`
FileError string `gorm:"type:varbinary(512)" json:"Error" yaml:"Error,omitempty"`
FileError string `gorm:"type:VARBINARY(512)" json:"Error" yaml:"Error,omitempty"`
ModTime int64 `json:"ModTime" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
CreatedIn int64 `json:"CreatedIn" yaml:"-"`

View file

@ -21,7 +21,7 @@ type Folders []Folder
// Folder represents a file system directory.
type Folder struct {
Path string `gorm:"type:VARBINARY(255);unique_index:idx_folders_path_root;" json:"Path" yaml:"Path"`
Path string `gorm:"type:VARBINARY(500);unique_index:idx_folders_path_root;" json:"Path" yaml:"Path"`
Root string `gorm:"type:VARBINARY(16);default:'';unique_index:idx_folders_path_root;" json:"Root" yaml:"Root,omitempty"`
FolderUID string `gorm:"type:VARBINARY(42);primary_key;" json:"UID,omitempty" yaml:"UID,omitempty"`
FolderType string `gorm:"type:VARBINARY(16);" json:"Type" yaml:"Type,omitempty"`

View file

@ -19,12 +19,12 @@ type Labels []Label
// Label is used for photo, album and location categorization
type Label struct {
ID uint `gorm:"primary_key" json:"ID" yaml:"-"`
LabelUID string `gorm:"type:varbinary(42);unique_index;" json:"UID" yaml:"UID"`
LabelSlug string `gorm:"type:varbinary(255);unique_index;" json:"Slug" yaml:"-"`
CustomSlug string `gorm:"type:varbinary(255);index;" json:"CustomSlug" yaml:"-"`
LabelUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
LabelSlug string `gorm:"type:VARBINARY(255);unique_index;" json:"Slug" yaml:"-"`
CustomSlug string `gorm:"type:VARBINARY(255);index;" json:"CustomSlug" yaml:"-"`
LabelName string `gorm:"type:VARCHAR(255);" json:"Name" yaml:"Name"`
LabelPriority int `gorm:"type:VARCHAR(255);" json:"Priority" yaml:"Priority,omitempty"`
LabelFavorite bool `gorm:"type:VARCHAR(255);" json:"Favorite" yaml:"Favorite,omitempty"`
LabelPriority int `json:"Priority" yaml:"Priority,omitempty"`
LabelFavorite bool `json:"Favorite" yaml:"Favorite,omitempty"`
LabelDescription string `gorm:"type:TEXT;" json:"Description" yaml:"Description,omitempty"`
LabelNotes string `gorm:"type:TEXT;" json:"Notes" yaml:"Notes,omitempty"`
LabelCategories []*Label `gorm:"many2many:categories;association_jointable_foreignkey:category_id" json:"-" yaml:"-"`

View file

@ -17,9 +17,9 @@ type Lens struct {
ID uint `gorm:"primary_key" json:"ID" yaml:"ID"`
LensSlug string `gorm:"type:VARBINARY(255);unique_index;" json:"Slug" yaml:"Slug,omitempty"`
LensName string `gorm:"type:VARCHAR(255);" json:"Name" yaml:"Name"`
LensMake string `json:"Make" yaml:"Make,omitempty"`
LensModel string `json:"Model" yaml:"Model,omitempty"`
LensType string `json:"Type" yaml:"Type,omitempty"`
LensMake string `gorm:"type:VARCHAR(255);" json:"Make" yaml:"Make,omitempty"`
LensModel string `gorm:"type:VARCHAR(255);" json:"Model" yaml:"Model,omitempty"`
LensType string `gorm:"type:VARCHAR(255);" json:"Type" yaml:"Type,omitempty"`
LensDescription string `gorm:"type:TEXT;" json:"Description,omitempty" yaml:"Description,omitempty"`
LensNotes string `gorm:"type:TEXT;" json:"Notes,omitempty" yaml:"Notes,omitempty"`
CreatedAt time.Time `json:"-" yaml:"-"`

View file

@ -54,9 +54,9 @@ type Photo struct {
TitleSrc string `gorm:"type:VARBINARY(8);" json:"TitleSrc" yaml:"TitleSrc,omitempty"`
PhotoDescription string `gorm:"type:TEXT;" json:"Description" yaml:"Description,omitempty"`
DescriptionSrc string `gorm:"type:VARBINARY(8);" json:"DescriptionSrc" yaml:"DescriptionSrc,omitempty"`
PhotoPath string `gorm:"type:VARBINARY(768);index:idx_photos_path_name;" json:"Path" yaml:"-"`
PhotoPath string `gorm:"type:VARBINARY(500);index:idx_photos_path_name;" json:"Path" yaml:"-"`
PhotoName string `gorm:"type:VARBINARY(255);index:idx_photos_path_name;" json:"Name" yaml:"-"`
OriginalName string `gorm:"type:VARBINARY(768);" json:"OriginalName" yaml:"OriginalName,omitempty"`
OriginalName string `gorm:"type:VARBINARY(755);" json:"OriginalName" yaml:"OriginalName,omitempty"`
PhotoFavorite bool `json:"Favorite" yaml:"Favorite,omitempty"`
PhotoSingle bool `json:"Single" yaml:"Single,omitempty"`
PhotoPrivate bool `json:"Private" yaml:"Private,omitempty"`

View file

@ -13,7 +13,7 @@ var placeMutex = sync.Mutex{}
// Place used to associate photos to places
type Place struct {
ID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"PlaceID" yaml:"PlaceID"`
PlaceLabel string `gorm:"type:VARBINARY(768);unique_index;" json:"Label" yaml:"Label"`
PlaceLabel string `gorm:"type:VARBINARY(755);unique_index;" json:"Label" yaml:"Label"`
PlaceCity string `gorm:"type:VARCHAR(255);" json:"City" yaml:"City,omitempty"`
PlaceState string `gorm:"type:VARCHAR(255);" json:"State" yaml:"State,omitempty"`
PlaceCountry string `gorm:"type:VARBINARY(2);" json:"Country" yaml:"Country,omitempty"`

View file

@ -57,7 +57,7 @@ type User struct {
RoleFamily bool `json:"RoleFamily" yaml:"RoleFamily,omitempty"`
RoleFriend bool `json:"RoleFriend" yaml:"RoleFriend,omitempty"`
WebDAV bool `gorm:"column:webdav" json:"WebDAV" yaml:"WebDAV,omitempty"`
StoragePath string `gorm:"column:storage_path;type:VARBINARY(255);" json:"StoragePath" yaml:"StoragePath,omitempty"`
StoragePath string `gorm:"column:storage_path;type:VARBINARY(500);" json:"StoragePath" yaml:"StoragePath,omitempty"`
CanInvite bool `json:"CanInvite" yaml:"CanInvite,omitempty"`
InviteToken string `gorm:"type:VARBINARY(32);" json:"-" yaml:"-"`
InvitedBy string `gorm:"type:VARBINARY(32);" json:"-" yaml:"-"`

View file

@ -199,7 +199,7 @@ func (m *Moments) Start() (err error) {
continue
}
if err := form.ParseQueryString(&f); err != nil {
if err := form.Unserialize(&f, a.AlbumFilter); err != nil {
log.Errorf("moments: %s", err.Error())
} else {
w := txt.Words(f.Label)

View file

@ -127,8 +127,8 @@ func AlbumSearch(f form.AlbumSearch) (results AlbumResults, err error) {
}
if f.Query != "" {
likeString := "%" + strings.ToLower(f.Query) + "%"
s = s.Where("LOWER(albums.album_title) LIKE ? OR LOWER(albums.album_location) LIKE ?", likeString, likeString)
likeString := "%" + f.Query + "%"
s = s.Where("albums.album_title LIKE ? OR albums.album_location LIKE ?", likeString, likeString)
}
if f.Type != "" {

View file

@ -5,6 +5,8 @@ import (
"strings"
"time"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
@ -136,12 +138,15 @@ func Geo(f form.GeoSearch) (results GeoResults, err error) {
}
}
if f.Name != "" {
s = s.Where("photos.photo_name LIKE ?", strings.ReplaceAll(f.Name, "*", "%"))
if strings.Contains(f.Name, OrSep) {
s = s.Where("photos.photo_name IN (?)", strings.Split(f.Name, OrSep))
} else if f.Name != "" {
s = s.Where("photos.photo_name LIKE ?", strings.ReplaceAll(fs.StripKnownExt(f.Name), "*", "%"))
}
// Filter by status.
if f.Archived {
s = s.Where("photos.photo_quality > -1")
s = s.Where("photos.deleted_at IS NOT NULL")
} else {
s = s.Where("photos.deleted_at IS NULL")

View file

@ -2,7 +2,6 @@ package query
import (
"fmt"
"strings"
"time"
"github.com/gosimple/slug"
@ -46,12 +45,12 @@ func Labels(f form.LabelSearch) (results []LabelResult, err error) {
var label entity.Label
slugString := slug.Make(f.Query)
likeString := "%" + strings.ToLower(f.Query) + "%"
likeString := "%" + f.Query + "%"
if result := Db().First(&label, "label_slug = ? OR custom_slug = ?", slugString, slugString); result.Error != nil {
log.Infof("search: label %s not found", txt.Quote(f.Query))
s = s.Where("LOWER(labels.label_name) LIKE ?", likeString)
s = s.Where("labels.label_name LIKE ?", likeString)
} else {
labelIds = append(labelIds, label.ID)

View file

@ -5,6 +5,8 @@ import (
"strings"
"time"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
@ -144,6 +146,7 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error
s = s.Where("photos.photo_quality = -1")
s = s.Where("photos.deleted_at IS NULL")
} else if f.Archived {
s = s.Where("photos.photo_quality > -1")
s = s.Where("photos.deleted_at IS NOT NULL")
} else {
s = s.Where("photos.deleted_at IS NULL")
@ -235,28 +238,40 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error
if strings.HasSuffix(p, "/") {
s = s.Where("photos.photo_path = ?", p[:len(p)-1])
} else if strings.Contains(p, OrSep) {
s = s.Where("photos.photo_path IN (?)", strings.Split(p, OrSep))
} else {
s = s.Where("photos.photo_path LIKE ?", strings.ReplaceAll(p, "*", "%"))
}
}
if f.Name != "" {
s = s.Where("photos.photo_name LIKE ?", strings.ReplaceAll(f.Name, "*", "%"))
if strings.Contains(f.Name, OrSep) {
s = s.Where("photos.photo_name IN (?)", strings.Split(f.Name, OrSep))
} else if f.Name != "" {
s = s.Where("photos.photo_name LIKE ?", strings.ReplaceAll(fs.StripKnownExt(f.Name), "*", "%"))
}
if f.Filename != "" {
if strings.Contains(f.Filename, OrSep) {
s = s.Where("files.file_name IN (?)", strings.Split(f.Filename, OrSep))
} else if f.Filename != "" {
s = s.Where("files.file_name LIKE ?", strings.ReplaceAll(f.Filename, "*", "%"))
}
if f.Original != "" {
if strings.Contains(f.Original, OrSep) {
s = s.Where("photos.original_name IN (?)", strings.Split(f.Original, OrSep))
} else if f.Original != "" {
s = s.Where("photos.original_name LIKE ?", strings.ReplaceAll(f.Original, "*", "%"))
}
if f.Title != "" {
s = s.Where("LOWER(photos.photo_title) LIKE ?", strings.ReplaceAll(strings.ToLower(f.Title), "*", "%"))
if strings.Contains(f.Title, OrSep) {
s = s.Where("photos.photo_title IN (?)", strings.Split(strings.ToLower(f.Title), OrSep))
} else if f.Title != "" {
s = s.Where("photos.photo_title LIKE ?", strings.ReplaceAll(strings.ToLower(f.Title), "*", "%"))
}
if f.Hash != "" {
if strings.Contains(f.Hash, OrSep) {
s = s.Where("files.file_hash IN (?)", strings.Split(strings.ToLower(f.Hash), OrSep))
} else if f.Hash != "" {
s = s.Where("files.file_hash IN (?)", strings.Split(strings.ToLower(f.Hash), OrSep))
}