stash/pkg/sqlite/image.go
gitgiggety 7164bb28ac
Filter studio hierarchy (#1397)
* Add basic support for hierarchical filters

Add a new `hierarchicalMultiCriterionHandlerBuilder` filter type which
can / will be used for filtering hierarchical things like the
parent/child relation of the studios.
On the frontend side a new IHierarchicalLabeledIdCriterion criterion
type has been added to accompany this new filter type.

* Refactor movieQueryBuilder to use filterBuilder

Refactor the movieQueryBuilder to use the filterBuilder just as scene,
image and gallery as well.

* Support specifying depth for studios filter

Add an optional depth field to the studios filter for scenes, images,
galleries and movies. When specified that number of included
(grant)children are shown as well. In other words: this adds support for
showing scenes set to child studios when searching on the parent studio.

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2021-06-03 20:52:19 +10:00

544 lines
16 KiB
Go

package sqlite
import (
"database/sql"
"fmt"
"github.com/stashapp/stash/pkg/models"
)
const imageTable = "images"
const imageIDColumn = "image_id"
const performersImagesTable = "performers_images"
const imagesTagsTable = "images_tags"
var imagesForGalleryQuery = selectAll(imageTable) + `
LEFT JOIN galleries_images as galleries_join on galleries_join.image_id = images.id
WHERE galleries_join.gallery_id = ?
GROUP BY images.id
`
var countImagesForGalleryQuery = `
SELECT gallery_id FROM galleries_images
WHERE gallery_id = ?
GROUP BY image_id
`
type imageQueryBuilder struct {
repository
}
func NewImageReaderWriter(tx dbi) *imageQueryBuilder {
return &imageQueryBuilder{
repository{
tx: tx,
tableName: imageTable,
idColumn: idColumn,
},
}
}
func (qb *imageQueryBuilder) Create(newObject models.Image) (*models.Image, error) {
var ret models.Image
if err := qb.insertObject(newObject, &ret); err != nil {
return nil, err
}
return &ret, nil
}
func (qb *imageQueryBuilder) Update(updatedObject models.ImagePartial) (*models.Image, error) {
const partial = true
if err := qb.update(updatedObject.ID, updatedObject, partial); err != nil {
return nil, err
}
return qb.find(updatedObject.ID)
}
func (qb *imageQueryBuilder) UpdateFull(updatedObject models.Image) (*models.Image, error) {
const partial = false
if err := qb.update(updatedObject.ID, updatedObject, partial); err != nil {
return nil, err
}
return qb.find(updatedObject.ID)
}
func (qb *imageQueryBuilder) IncrementOCounter(id int) (int, error) {
_, err := qb.tx.Exec(
`UPDATE `+imageTable+` SET o_counter = o_counter + 1 WHERE `+imageTable+`.id = ?`,
id,
)
if err != nil {
return 0, err
}
image, err := qb.find(id)
if err != nil {
return 0, err
}
return image.OCounter, nil
}
func (qb *imageQueryBuilder) DecrementOCounter(id int) (int, error) {
_, err := qb.tx.Exec(
`UPDATE `+imageTable+` SET o_counter = o_counter - 1 WHERE `+imageTable+`.id = ? and `+imageTable+`.o_counter > 0`,
id,
)
if err != nil {
return 0, err
}
image, err := qb.find(id)
if err != nil {
return 0, err
}
return image.OCounter, nil
}
func (qb *imageQueryBuilder) ResetOCounter(id int) (int, error) {
_, err := qb.tx.Exec(
`UPDATE `+imageTable+` SET o_counter = 0 WHERE `+imageTable+`.id = ?`,
id,
)
if err != nil {
return 0, err
}
image, err := qb.find(id)
if err != nil {
return 0, err
}
return image.OCounter, nil
}
func (qb *imageQueryBuilder) Destroy(id int) error {
return qb.destroyExisting([]int{id})
}
func (qb *imageQueryBuilder) Find(id int) (*models.Image, error) {
return qb.find(id)
}
func (qb *imageQueryBuilder) FindMany(ids []int) ([]*models.Image, error) {
var images []*models.Image
for _, id := range ids {
image, err := qb.Find(id)
if err != nil {
return nil, err
}
if image == nil {
return nil, fmt.Errorf("image with id %d not found", id)
}
images = append(images, image)
}
return images, nil
}
func (qb *imageQueryBuilder) find(id int) (*models.Image, error) {
var ret models.Image
if err := qb.get(id, &ret); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &ret, nil
}
func (qb *imageQueryBuilder) FindByChecksum(checksum string) (*models.Image, error) {
query := "SELECT * FROM images WHERE checksum = ? LIMIT 1"
args := []interface{}{checksum}
return qb.queryImage(query, args)
}
func (qb *imageQueryBuilder) FindByPath(path string) (*models.Image, error) {
query := selectAll(imageTable) + "WHERE path = ? LIMIT 1"
args := []interface{}{path}
return qb.queryImage(query, args)
}
func (qb *imageQueryBuilder) FindByGalleryID(galleryID int) ([]*models.Image, error) {
args := []interface{}{galleryID}
return qb.queryImages(imagesForGalleryQuery+qb.getImageSort(nil), args)
}
func (qb *imageQueryBuilder) CountByGalleryID(galleryID int) (int, error) {
args := []interface{}{galleryID}
return qb.runCountQuery(qb.buildCountQuery(countImagesForGalleryQuery), args)
}
func (qb *imageQueryBuilder) Count() (int, error) {
return qb.runCountQuery(qb.buildCountQuery("SELECT images.id FROM images"), nil)
}
func (qb *imageQueryBuilder) Size() (float64, error) {
return qb.runSumQuery("SELECT SUM(cast(size as double)) as sum FROM images", nil)
}
func (qb *imageQueryBuilder) All() ([]*models.Image, error) {
return qb.queryImages(selectAll(imageTable)+qb.getImageSort(nil), nil)
}
func (qb *imageQueryBuilder) validateFilter(imageFilter *models.ImageFilterType) error {
const and = "AND"
const or = "OR"
const not = "NOT"
if imageFilter.And != nil {
if imageFilter.Or != nil {
return illegalFilterCombination(and, or)
}
if imageFilter.Not != nil {
return illegalFilterCombination(and, not)
}
return qb.validateFilter(imageFilter.And)
}
if imageFilter.Or != nil {
if imageFilter.Not != nil {
return illegalFilterCombination(or, not)
}
return qb.validateFilter(imageFilter.Or)
}
if imageFilter.Not != nil {
return qb.validateFilter(imageFilter.Not)
}
return nil
}
func (qb *imageQueryBuilder) makeFilter(imageFilter *models.ImageFilterType) *filterBuilder {
query := &filterBuilder{}
if imageFilter.And != nil {
query.and(qb.makeFilter(imageFilter.And))
}
if imageFilter.Or != nil {
query.or(qb.makeFilter(imageFilter.Or))
}
if imageFilter.Not != nil {
query.not(qb.makeFilter(imageFilter.Not))
}
query.handleCriterionFunc(stringCriterionHandler(imageFilter.Path, "images.path"))
query.handleCriterionFunc(intCriterionHandler(imageFilter.Rating, "images.rating"))
query.handleCriterionFunc(intCriterionHandler(imageFilter.OCounter, "images.o_counter"))
query.handleCriterionFunc(boolCriterionHandler(imageFilter.Organized, "images.organized"))
query.handleCriterionFunc(resolutionCriterionHandler(imageFilter.Resolution, "images.height", "images.width"))
query.handleCriterionFunc(imageIsMissingCriterionHandler(qb, imageFilter.IsMissing))
query.handleCriterionFunc(imageTagsCriterionHandler(qb, imageFilter.Tags))
query.handleCriterionFunc(imageTagCountCriterionHandler(qb, imageFilter.TagCount))
query.handleCriterionFunc(imageGalleriesCriterionHandler(qb, imageFilter.Galleries))
query.handleCriterionFunc(imagePerformersCriterionHandler(qb, imageFilter.Performers))
query.handleCriterionFunc(imagePerformerCountCriterionHandler(qb, imageFilter.PerformerCount))
query.handleCriterionFunc(imageStudioCriterionHandler(qb, imageFilter.Studios))
query.handleCriterionFunc(imagePerformerTagsCriterionHandler(qb, imageFilter.PerformerTags))
return query
}
func (qb *imageQueryBuilder) makeQuery(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) {
if imageFilter == nil {
imageFilter = &models.ImageFilterType{}
}
if findFilter == nil {
findFilter = &models.FindFilterType{}
}
query := qb.newQuery()
query.body = selectDistinctIDs(imageTable)
if q := findFilter.Q; q != nil && *q != "" {
searchColumns := []string{"images.title", "images.path", "images.checksum"}
clause, thisArgs := getSearchBinding(searchColumns, *q, false)
query.addWhere(clause)
query.addArg(thisArgs...)
}
if err := qb.validateFilter(imageFilter); err != nil {
return nil, err
}
filter := qb.makeFilter(imageFilter)
query.addFilter(filter)
query.sortAndPagination = qb.getImageSort(findFilter) + getPagination(findFilter)
return &query, nil
}
func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, int, error) {
query, err := qb.makeQuery(imageFilter, findFilter)
if err != nil {
return nil, 0, err
}
idsResult, countResult, err := query.executeFind()
if err != nil {
return nil, 0, err
}
var images []*models.Image
for _, id := range idsResult {
image, err := qb.Find(id)
if err != nil {
return nil, 0, err
}
images = append(images, image)
}
return images, countResult, nil
}
func (qb *imageQueryBuilder) QueryCount(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (int, error) {
query, err := qb.makeQuery(imageFilter, findFilter)
if err != nil {
return 0, err
}
return query.executeCount()
}
func imageIsMissingCriterionHandler(qb *imageQueryBuilder, isMissing *string) criterionHandlerFunc {
return func(f *filterBuilder) {
if isMissing != nil && *isMissing != "" {
switch *isMissing {
case "studio":
f.addWhere("images.studio_id IS NULL")
case "performers":
qb.performersRepository().join(f, "performers_join", "images.id")
f.addWhere("performers_join.image_id IS NULL")
case "galleries":
qb.galleriesRepository().join(f, "galleries_join", "images.id")
f.addWhere("galleries_join.image_id IS NULL")
case "tags":
qb.tagsRepository().join(f, "tags_join", "images.id")
f.addWhere("tags_join.image_id IS NULL")
default:
f.addWhere("(images." + *isMissing + " IS NULL OR TRIM(images." + *isMissing + ") = '')")
}
}
}
}
func (qb *imageQueryBuilder) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder {
return multiCriterionHandlerBuilder{
primaryTable: imageTable,
foreignTable: foreignTable,
joinTable: joinTable,
primaryFK: imageIDColumn,
foreignFK: foreignFK,
addJoinsFunc: addJoinsFunc,
}
}
func imageTagsCriterionHandler(qb *imageQueryBuilder, tags *models.MultiCriterionInput) criterionHandlerFunc {
h := joinedMultiCriterionHandlerBuilder{
primaryTable: imageTable,
joinTable: imagesTagsTable,
joinAs: "tags_join",
primaryFK: imageIDColumn,
foreignFK: tagIDColumn,
addJoinTable: func(f *filterBuilder) {
qb.tagsRepository().join(f, "tags_join", "images.id")
},
}
return h.handler(tags)
}
func imageTagCountCriterionHandler(qb *imageQueryBuilder, tagCount *models.IntCriterionInput) criterionHandlerFunc {
h := countCriterionHandlerBuilder{
primaryTable: imageTable,
joinTable: imagesTagsTable,
primaryFK: imageIDColumn,
}
return h.handler(tagCount)
}
func imageGalleriesCriterionHandler(qb *imageQueryBuilder, galleries *models.MultiCriterionInput) criterionHandlerFunc {
addJoinsFunc := func(f *filterBuilder) {
qb.galleriesRepository().join(f, "galleries_join", "images.id")
f.addJoin(galleryTable, "", "galleries_join.gallery_id = galleries.id")
}
h := qb.getMultiCriterionHandlerBuilder(galleryTable, galleriesImagesTable, galleryIDColumn, addJoinsFunc)
return h.handler(galleries)
}
func imagePerformersCriterionHandler(qb *imageQueryBuilder, performers *models.MultiCriterionInput) criterionHandlerFunc {
h := joinedMultiCriterionHandlerBuilder{
primaryTable: imageTable,
joinTable: performersImagesTable,
joinAs: "performers_join",
primaryFK: imageIDColumn,
foreignFK: performerIDColumn,
addJoinTable: func(f *filterBuilder) {
qb.performersRepository().join(f, "performers_join", "images.id")
},
}
return h.handler(performers)
}
func imagePerformerCountCriterionHandler(qb *imageQueryBuilder, performerCount *models.IntCriterionInput) criterionHandlerFunc {
h := countCriterionHandlerBuilder{
primaryTable: imageTable,
joinTable: performersImagesTable,
primaryFK: imageIDColumn,
}
return h.handler(performerCount)
}
func imageStudioCriterionHandler(qb *imageQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
h := hierarchicalMultiCriterionHandlerBuilder{
primaryTable: imageTable,
foreignTable: studioTable,
foreignFK: studioIDColumn,
derivedTable: "studio",
parentFK: "parent_id",
}
return h.handler(studios)
}
func imagePerformerTagsCriterionHandler(qb *imageQueryBuilder, performerTagsFilter *models.MultiCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) {
if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 {
qb.performersRepository().join(f, "performers_join", "images.id")
f.addJoin("performers_tags", "performer_tags_join", "performers_join.performer_id = performer_tags_join.performer_id")
var args []interface{}
for _, tagID := range performerTagsFilter.Value {
args = append(args, tagID)
}
if performerTagsFilter.Modifier == models.CriterionModifierIncludes {
// includes any of the provided ids
f.addWhere("performer_tags_join.tag_id IN "+getInBinding(len(performerTagsFilter.Value)), args...)
} else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll {
// includes all of the provided ids
f.addWhere("performer_tags_join.tag_id IN "+getInBinding(len(performerTagsFilter.Value)), args...)
f.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value)))
} else if performerTagsFilter.Modifier == models.CriterionModifierExcludes {
f.addWhere(fmt.Sprintf(`not exists
(select performers_images.performer_id from performers_images
left join performers_tags on performers_tags.performer_id = performers_images.performer_id where
performers_images.image_id = images.id AND
performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value))), args...)
}
}
}
}
func (qb *imageQueryBuilder) getImageSort(findFilter *models.FindFilterType) string {
if findFilter == nil {
return " ORDER BY images.path ASC "
}
sort := findFilter.GetSort("title")
direction := findFilter.GetDirection()
switch sort {
case "tag_count":
return getCountSort(imageTable, imagesTagsTable, imageIDColumn, direction)
case "performer_count":
return getCountSort(imageTable, performersImagesTable, imageIDColumn, direction)
default:
return getSort(sort, direction, "images")
}
}
func (qb *imageQueryBuilder) queryImage(query string, args []interface{}) (*models.Image, error) {
results, err := qb.queryImages(query, args)
if err != nil || len(results) < 1 {
return nil, err
}
return results[0], nil
}
func (qb *imageQueryBuilder) queryImages(query string, args []interface{}) ([]*models.Image, error) {
var ret models.Images
if err := qb.query(query, args, &ret); err != nil {
return nil, err
}
return []*models.Image(ret), nil
}
func (qb *imageQueryBuilder) galleriesRepository() *joinRepository {
return &joinRepository{
repository: repository{
tx: qb.tx,
tableName: galleriesImagesTable,
idColumn: imageIDColumn,
},
fkColumn: galleryIDColumn,
}
}
func (qb *imageQueryBuilder) GetGalleryIDs(imageID int) ([]int, error) {
return qb.galleriesRepository().getIDs(imageID)
}
func (qb *imageQueryBuilder) UpdateGalleries(imageID int, galleryIDs []int) error {
// Delete the existing joins and then create new ones
return qb.galleriesRepository().replace(imageID, galleryIDs)
}
func (qb *imageQueryBuilder) performersRepository() *joinRepository {
return &joinRepository{
repository: repository{
tx: qb.tx,
tableName: performersImagesTable,
idColumn: imageIDColumn,
},
fkColumn: performerIDColumn,
}
}
func (qb *imageQueryBuilder) GetPerformerIDs(imageID int) ([]int, error) {
return qb.performersRepository().getIDs(imageID)
}
func (qb *imageQueryBuilder) UpdatePerformers(imageID int, performerIDs []int) error {
// Delete the existing joins and then create new ones
return qb.performersRepository().replace(imageID, performerIDs)
}
func (qb *imageQueryBuilder) tagsRepository() *joinRepository {
return &joinRepository{
repository: repository{
tx: qb.tx,
tableName: imagesTagsTable,
idColumn: imageIDColumn,
},
fkColumn: tagIDColumn,
}
}
func (qb *imageQueryBuilder) GetTagIDs(imageID int) ([]int, error) {
return qb.tagsRepository().getIDs(imageID)
}
func (qb *imageQueryBuilder) UpdateTags(imageID int, tagIDs []int) error {
// Delete the existing joins and then create new ones
return qb.tagsRepository().replace(imageID, tagIDs)
}