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>
This commit is contained in:
gitgiggety 2021-06-03 12:52:19 +02:00 committed by GitHub
parent 508f7b84f2
commit 7164bb28ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 595 additions and 100 deletions

View file

@ -122,7 +122,7 @@ input SceneFilterType {
"""Filter to only include scenes missing this property"""
is_missing: String
"""Filter to only include scenes with this studio"""
studios: MultiCriterionInput
studios: HierarchicalMultiCriterionInput
"""Filter to only include scenes with this movie"""
movies: MultiCriterionInput
"""Filter to only include scenes with these tags"""
@ -145,7 +145,7 @@ input SceneFilterType {
input MovieFilterType {
"""Filter to only include movies with this studio"""
studios: MultiCriterionInput
studios: HierarchicalMultiCriterionInput
"""Filter to only include movies missing this property"""
is_missing: String
"""Filter by url"""
@ -189,7 +189,7 @@ input GalleryFilterType {
"""Filter by average image resolution"""
average_resolution: ResolutionEnum
"""Filter to only include galleries with this studio"""
studios: MultiCriterionInput
studios: HierarchicalMultiCriterionInput
"""Filter to only include galleries with these tags"""
tags: MultiCriterionInput
"""Filter by tag count"""
@ -254,7 +254,7 @@ input ImageFilterType {
"""Filter to only include images missing this property"""
is_missing: String
"""Filter to only include images with this studio"""
studios: MultiCriterionInput
studios: HierarchicalMultiCriterionInput
"""Filter to only include images with these tags"""
tags: MultiCriterionInput
"""Filter by tag count"""
@ -311,3 +311,9 @@ input GenderCriterionInput {
value: GenderEnum
modifier: CriterionModifier!
}
input HierarchicalMultiCriterionInput {
value: [ID!]
modifier: CriterionModifier!
depth: Int!
}

View file

@ -505,9 +505,10 @@ func (me *contentDirectoryService) getStudios() []interface{} {
func (me *contentDirectoryService) getStudioScenes(paths []string, host string) []interface{} {
sceneFilter := &models.SceneFilterType{
Studios: &models.MultiCriterionInput{
Studios: &models.HierarchicalMultiCriterionInput{
Modifier: models.CriterionModifierIncludes,
Value: []string{paths[0]},
Depth: 0,
},
}

View file

@ -19,9 +19,10 @@ func CountByPerformerID(r models.GalleryReader, id int) (int, error) {
func CountByStudioID(r models.GalleryReader, id int) (int, error) {
filter := &models.GalleryFilterType{
Studios: &models.MultiCriterionInput{
Studios: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(id)},
Modifier: models.CriterionModifierIncludes,
Depth: 0,
},
}

View file

@ -19,9 +19,10 @@ func CountByPerformerID(r models.ImageReader, id int) (int, error) {
func CountByStudioID(r models.ImageReader, id int) (int, error) {
filter := &models.ImageFilterType{
Studios: &models.MultiCriterionInput{
Studios: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(id)},
Modifier: models.CriterionModifierIncludes,
Depth: 0,
},
}

View file

@ -87,6 +87,7 @@ type filterBuilder struct {
joins joins
whereClauses []sqlClause
havingClauses []sqlClause
withClauses []sqlClause
err error
}
@ -169,6 +170,15 @@ func (f *filterBuilder) addHaving(sql string, args ...interface{}) {
f.havingClauses = append(f.havingClauses, makeClause(sql, args...))
}
// addWith adds a with clause and arguments to the filter
func (f *filterBuilder) addWith(sql string, args ...interface{}) {
if sql == "" {
return
}
f.withClauses = append(f.withClauses, makeClause(sql, args...))
}
func (f *filterBuilder) getSubFilterClause(clause, subFilterClause string) string {
ret := clause
@ -226,6 +236,21 @@ func (f *filterBuilder) generateHavingClauses() (string, []interface{}) {
return clause, args
}
func (f *filterBuilder) generateWithClauses() (string, []interface{}) {
var clauses []string
var args []interface{}
for _, w := range f.withClauses {
clauses = append(clauses, w.sql)
args = append(args, w.args...)
}
if len(clauses) > 0 {
return strings.Join(clauses, ", "), args
}
return "", nil
}
// getAllJoins returns all of the joins in this filter and any sub-filter(s).
// Redundant joins will not be duplicated in the return value.
func (f *filterBuilder) getAllJoins() joins {
@ -501,6 +526,58 @@ func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCrit
m.addJoinTable(f)
stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(f)
}
}
}
type hierarchicalMultiCriterionHandlerBuilder struct {
primaryTable string
foreignTable string
foreignFK string
derivedTable string
parentFK string
}
func (m *hierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) {
if criterion != nil && len(criterion.Value) > 0 {
var args []interface{}
for _, value := range criterion.Value {
args = append(args, value)
}
inCount := len(args)
f.addJoin(m.derivedTable, "", fmt.Sprintf("%s.child_id = %s.%s", m.derivedTable, m.primaryTable, m.foreignFK))
var depthCondition string
if criterion.Depth != -1 {
depthCondition = "WHERE depth < ?"
args = append(args, criterion.Depth)
}
withClause := fmt.Sprintf(
"RECURSIVE %s AS (SELECT id as id, id as child_id, 0 as depth FROM %s WHERE id in %s UNION SELECT p.id, c.id, depth + 1 FROM %s as c INNER JOIN %s as p ON c.%s = p.child_id %s)",
m.derivedTable,
m.foreignTable,
getInBinding(inCount),
m.foreignTable,
m.derivedTable,
m.parentFK,
depthCondition,
)
f.addWith(withClause, args...)
if criterion.Modifier == models.CriterionModifierIncludes {
f.addWhere(fmt.Sprintf("%s.id IS NOT NULL", m.derivedTable))
} else if criterion.Modifier == models.CriterionModifierIncludesAll {
f.addWhere(fmt.Sprintf("%s.id IS NOT NULL", m.derivedTable))
f.addHaving(fmt.Sprintf("count(distinct %s.id) IS %d", m.derivedTable, inCount))
} else if criterion.Modifier == models.CriterionModifierExcludes {
f.addWhere(fmt.Sprintf("%s.id IS NULL", m.derivedTable))
}
}
}
}

View file

@ -382,11 +382,14 @@ func galleryImageCountCriterionHandler(qb *galleryQueryBuilder, imageCount *mode
return h.handler(imageCount)
}
func galleryStudioCriterionHandler(qb *galleryQueryBuilder, studios *models.MultiCriterionInput) criterionHandlerFunc {
addJoinsFunc := func(f *filterBuilder) {
f.addJoin(studioTable, "studio", "studio.id = galleries.studio_id")
func galleryStudioCriterionHandler(qb *galleryQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
h := hierarchicalMultiCriterionHandlerBuilder{
primaryTable: galleryTable,
foreignTable: studioTable,
foreignFK: studioIDColumn,
derivedTable: "studio",
parentFK: "parent_id",
}
h := qb.getMultiCriterionHandlerBuilder("studio", "", studioIDColumn, addJoinsFunc)
return h.handler(studios)
}

View file

@ -675,11 +675,12 @@ func TestGalleryQueryTags(t *testing.T) {
func TestGalleryQueryStudio(t *testing.T) {
withTxn(func(r models.Repository) error {
sqb := r.Gallery()
studioCriterion := models.MultiCriterionInput{
studioCriterion := models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithGallery]),
},
Modifier: models.CriterionModifierIncludes,
Depth: 0,
}
galleryFilter := models.GalleryFilterType{
@ -693,11 +694,12 @@ func TestGalleryQueryStudio(t *testing.T) {
// ensure id is correct
assert.Equal(t, galleryIDs[galleryIdxWithStudio], galleries[0].ID)
studioCriterion = models.MultiCriterionInput{
studioCriterion = models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithGallery]),
},
Modifier: models.CriterionModifierExcludes,
Depth: 0,
}
q := getGalleryStringValue(galleryIdxWithStudio, titleField)
@ -712,6 +714,64 @@ func TestGalleryQueryStudio(t *testing.T) {
})
}
func TestGalleryQueryStudioDepth(t *testing.T) {
withTxn(func(r models.Repository) error {
sqb := r.Gallery()
studioCriterion := models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithGrandChild]),
},
Modifier: models.CriterionModifierIncludes,
Depth: 2,
}
galleryFilter := models.GalleryFilterType{
Studios: &studioCriterion,
}
galleries := queryGallery(t, sqb, &galleryFilter, nil)
assert.Len(t, galleries, 1)
studioCriterion.Depth = 1
galleries = queryGallery(t, sqb, &galleryFilter, nil)
assert.Len(t, galleries, 0)
studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}
galleries = queryGallery(t, sqb, &galleryFilter, nil)
assert.Len(t, galleries, 1)
// ensure id is correct
assert.Equal(t, galleryIDs[galleryIdxWithGrandChildStudio], galleries[0].ID)
studioCriterion = models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithGrandChild]),
},
Modifier: models.CriterionModifierExcludes,
Depth: 2,
}
q := getGalleryStringValue(galleryIdxWithGrandChildStudio, pathField)
findFilter := models.FindFilterType{
Q: &q,
}
galleries = queryGallery(t, sqb, &galleryFilter, &findFilter)
assert.Len(t, galleries, 0)
studioCriterion.Depth = 1
galleries = queryGallery(t, sqb, &galleryFilter, &findFilter)
assert.Len(t, galleries, 1)
studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}
galleries = queryGallery(t, sqb, &galleryFilter, &findFilter)
assert.Len(t, galleries, 0)
return nil
})
}
func TestGalleryQueryPerformerTags(t *testing.T) {
withTxn(func(r models.Repository) error {
sqb := r.Gallery()

View file

@ -408,11 +408,14 @@ func imagePerformerCountCriterionHandler(qb *imageQueryBuilder, performerCount *
return h.handler(performerCount)
}
func imageStudioCriterionHandler(qb *imageQueryBuilder, studios *models.MultiCriterionInput) criterionHandlerFunc {
addJoinsFunc := func(f *filterBuilder) {
f.addJoin(studioTable, "studio", "studio.id = images.studio_id")
func imageStudioCriterionHandler(qb *imageQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
h := hierarchicalMultiCriterionHandlerBuilder{
primaryTable: imageTable,
foreignTable: studioTable,
foreignFK: studioIDColumn,
derivedTable: "studio",
parentFK: "parent_id",
}
h := qb.getMultiCriterionHandlerBuilder("studio", "", studioIDColumn, addJoinsFunc)
return h.handler(studios)
}

View file

@ -783,11 +783,12 @@ func TestImageQueryTags(t *testing.T) {
func TestImageQueryStudio(t *testing.T) {
withTxn(func(r models.Repository) error {
sqb := r.Image()
studioCriterion := models.MultiCriterionInput{
studioCriterion := models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithImage]),
},
Modifier: models.CriterionModifierIncludes,
Depth: 0,
}
imageFilter := models.ImageFilterType{
@ -804,11 +805,12 @@ func TestImageQueryStudio(t *testing.T) {
// ensure id is correct
assert.Equal(t, imageIDs[imageIdxWithStudio], images[0].ID)
studioCriterion = models.MultiCriterionInput{
studioCriterion = models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithImage]),
},
Modifier: models.CriterionModifierExcludes,
Depth: 0,
}
q := getImageStringValue(imageIdxWithStudio, titleField)
@ -826,6 +828,64 @@ func TestImageQueryStudio(t *testing.T) {
})
}
func TestImageQueryStudioDepth(t *testing.T) {
withTxn(func(r models.Repository) error {
sqb := r.Image()
studioCriterion := models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithGrandChild]),
},
Modifier: models.CriterionModifierIncludes,
Depth: 2,
}
imageFilter := models.ImageFilterType{
Studios: &studioCriterion,
}
images := queryImages(t, sqb, &imageFilter, nil)
assert.Len(t, images, 1)
studioCriterion.Depth = 1
images = queryImages(t, sqb, &imageFilter, nil)
assert.Len(t, images, 0)
studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}
images = queryImages(t, sqb, &imageFilter, nil)
assert.Len(t, images, 1)
// ensure id is correct
assert.Equal(t, imageIDs[imageIdxWithGrandChildStudio], images[0].ID)
studioCriterion = models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithGrandChild]),
},
Modifier: models.CriterionModifierExcludes,
Depth: 2,
}
q := getImageStringValue(imageIdxWithGrandChildStudio, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
images = queryImages(t, sqb, &imageFilter, &findFilter)
assert.Len(t, images, 0)
studioCriterion.Depth = 1
images = queryImages(t, sqb, &imageFilter, &findFilter)
assert.Len(t, images, 1)
studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}
images = queryImages(t, sqb, &imageFilter, &findFilter)
assert.Len(t, images, 0)
return nil
})
}
func queryImages(t *testing.T, sqb models.ImageReader, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) []*models.Image {
images, _, err := sqb.Query(imageFilter, findFilter)
if err != nil {

View file

@ -8,6 +8,7 @@ import (
)
const movieTable = "movies"
const movieIDColumn = "movie_id"
type movieQueryBuilder struct {
repository
@ -114,6 +115,16 @@ func (qb *movieQueryBuilder) All() ([]*models.Movie, error) {
return qb.queryMovies(selectAll("movies")+qb.getMovieSort(nil), nil)
}
func (qb *movieQueryBuilder) makeFilter(movieFilter *models.MovieFilterType) *filterBuilder {
query := &filterBuilder{}
query.handleCriterionFunc(movieIsMissingCriterionHandler(qb, movieFilter.IsMissing))
query.handleCriterionFunc(stringCriterionHandler(movieFilter.URL, "movies.url"))
query.handleCriterionFunc(movieStudioCriterionHandler(qb, movieFilter.Studios))
return query
}
func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) ([]*models.Movie, int, error) {
if findFilter == nil {
findFilter = &models.FindFilterType{}
@ -125,11 +136,6 @@ func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilt
query := qb.newQuery()
query.body = selectDistinctIDs("movies")
query.body += `
left join movies_scenes as scenes_join on scenes_join.movie_id = movies.id
left join scenes on scenes_join.scene_id = scenes.id
left join studios as studio on studio.id = movies.studio_id
`
if q := findFilter.Q; q != nil && *q != "" {
searchColumns := []string{"movies.name"}
@ -138,36 +144,9 @@ func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilt
query.addArg(thisArgs...)
}
if studiosFilter := movieFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 {
for _, studioID := range studiosFilter.Value {
query.addArg(studioID)
}
filter := qb.makeFilter(movieFilter)
whereClause, havingClause := getMultiCriterionClause("movies", "studio", "", "", "studio_id", studiosFilter)
query.addWhere(whereClause)
query.addHaving(havingClause)
}
if isMissingFilter := movieFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
switch *isMissingFilter {
case "front_image":
query.body += `left join movies_images on movies_images.movie_id = movies.id
`
query.addWhere("movies_images.front_image IS NULL")
case "back_image":
query.body += `left join movies_images on movies_images.movie_id = movies.id
`
query.addWhere("movies_images.back_image IS NULL")
case "scenes":
query.body += `left join movies_scenes on movies_scenes.movie_id = movies.id
`
query.addWhere("movies_scenes.scene_id IS NULL")
default:
query.addWhere("movies." + *isMissingFilter + " IS NULL")
}
}
query.handleStringCriterionInput(movieFilter.URL, "movies.url")
query.addFilter(filter)
query.sortAndPagination = qb.getMovieSort(findFilter) + getPagination(findFilter)
idsResult, countResult, err := query.executeFind()
@ -188,6 +167,38 @@ func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilt
return movies, countResult, nil
}
func movieIsMissingCriterionHandler(qb *movieQueryBuilder, isMissing *string) criterionHandlerFunc {
return func(f *filterBuilder) {
if isMissing != nil && *isMissing != "" {
switch *isMissing {
case "front_image":
f.addJoin("movies_images", "", "movies_images.movie_id = movies.id")
f.addWhere("movies_images.front_image IS NULL")
case "back_image":
f.addJoin("movies_images", "", "movies_images.movie_id = movies.id")
f.addWhere("movies_images.back_image IS NULL")
case "scenes":
f.addJoin("movies_scenes", "", "movies_scenes.movie_id = movies.id")
f.addWhere("movies_scenes.scene_id IS NULL")
default:
f.addWhere("(movies." + *isMissing + " IS NULL OR TRIM(movies." + *isMissing + ") = '')")
}
}
}
}
func movieStudioCriterionHandler(qb *movieQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
h := hierarchicalMultiCriterionHandlerBuilder{
primaryTable: movieTable,
foreignTable: studioTable,
foreignFK: studioIDColumn,
derivedTable: "studio",
parentFK: "parent_id",
}
return h.handler(studios)
}
func (qb *movieQueryBuilder) getMovieSort(findFilter *models.FindFilterType) string {
var sort string
var direction string

View file

@ -76,11 +76,12 @@ func TestMovieFindByNames(t *testing.T) {
func TestMovieQueryStudio(t *testing.T) {
withTxn(func(r models.Repository) error {
mqb := r.Movie()
studioCriterion := models.MultiCriterionInput{
studioCriterion := models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithMovie]),
},
Modifier: models.CriterionModifierIncludes,
Depth: 0,
}
movieFilter := models.MovieFilterType{
@ -97,11 +98,12 @@ func TestMovieQueryStudio(t *testing.T) {
// ensure id is correct
assert.Equal(t, movieIDs[movieIdxWithStudio], movies[0].ID)
studioCriterion = models.MultiCriterionInput{
studioCriterion = models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithMovie]),
},
Modifier: models.CriterionModifierExcludes,
Depth: 0,
}
q := getMovieStringValue(movieIdxWithStudio, titleField)

View file

@ -3,6 +3,7 @@ package sqlite
import (
"fmt"
"regexp"
"strings"
"github.com/stashapp/stash/pkg/models"
)
@ -16,6 +17,7 @@ type queryBuilder struct {
whereClauses []string
havingClauses []string
args []interface{}
withClauses []string
sortAndPagination string
@ -30,7 +32,7 @@ func (qb queryBuilder) executeFind() ([]int, int, error) {
body := qb.body
body += qb.joins.toSQL()
return qb.repository.executeFindQuery(body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses)
return qb.repository.executeFindQuery(body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses, qb.withClauses)
}
func (qb queryBuilder) executeCount() (int, error) {
@ -41,8 +43,13 @@ func (qb queryBuilder) executeCount() (int, error) {
body := qb.body
body += qb.joins.toSQL()
withClause := ""
if len(qb.withClauses) > 0 {
withClause = "WITH " + strings.Join(qb.withClauses, ", ") + " "
}
body = qb.repository.buildQueryBody(body, qb.whereClauses, qb.havingClauses)
countQuery := qb.repository.buildCountQuery(body)
countQuery := withClause + qb.repository.buildCountQuery(body)
return qb.repository.runCountQuery(countQuery, qb.args)
}
@ -62,6 +69,14 @@ func (qb *queryBuilder) addHaving(clauses ...string) {
}
}
func (qb *queryBuilder) addWith(clauses ...string) {
for _, clause := range clauses {
if len(clause) > 0 {
qb.withClauses = append(qb.withClauses, clause)
}
}
}
func (qb *queryBuilder) addArg(args ...interface{}) {
qb.args = append(qb.args, args...)
}
@ -87,7 +102,17 @@ func (qb *queryBuilder) addFilter(f *filterBuilder) {
return
}
clause, args := f.generateWhereClauses()
clause, args := f.generateWithClauses()
if len(clause) > 0 {
qb.addWith(clause)
}
if len(args) > 0 {
// WITH clause always comes first and thus precedes alk args
qb.args = append(args, qb.args...)
}
clause, args = f.generateWhereClauses()
if len(clause) > 0 {
qb.addWhere(clause)
}

View file

@ -246,11 +246,16 @@ func (r *repository) buildQueryBody(body string, whereClauses []string, havingCl
return body
}
func (r *repository) executeFindQuery(body string, args []interface{}, sortAndPagination string, whereClauses []string, havingClauses []string) ([]int, int, error) {
func (r *repository) executeFindQuery(body string, args []interface{}, sortAndPagination string, whereClauses []string, havingClauses []string, withClauses []string) ([]int, int, error) {
body = r.buildQueryBody(body, whereClauses, havingClauses)
countQuery := r.buildCountQuery(body)
idsQuery := body + sortAndPagination
withClause := ""
if len(withClauses) > 0 {
withClause = "WITH " + strings.Join(withClauses, ", ") + " "
}
countQuery := withClause + r.buildCountQuery(body)
idsQuery := withClause + body + sortAndPagination
// Perform query and fetch result
logger.Tracef("SQL: %s, args: %v", idsQuery, args)

View file

@ -588,11 +588,14 @@ func scenePerformerCountCriterionHandler(qb *sceneQueryBuilder, performerCount *
return h.handler(performerCount)
}
func sceneStudioCriterionHandler(qb *sceneQueryBuilder, studios *models.MultiCriterionInput) criterionHandlerFunc {
addJoinsFunc := func(f *filterBuilder) {
f.addJoin("studios", "studio", "studio.id = scenes.studio_id")
func sceneStudioCriterionHandler(qb *sceneQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
h := hierarchicalMultiCriterionHandlerBuilder{
primaryTable: sceneTable,
foreignTable: studioTable,
foreignFK: studioIDColumn,
derivedTable: "studio",
parentFK: "parent_id",
}
h := qb.getMultiCriterionHandlerBuilder("studio", "", studioIDColumn, addJoinsFunc)
return h.handler(studios)
}

View file

@ -249,7 +249,7 @@ func (qb *sceneMarkerQueryBuilder) Query(sceneMarkerFilter *models.SceneMarkerFi
}
sortAndPagination := qb.getSceneMarkerSort(findFilter) + getPagination(findFilter)
idsResult, countResult, err := qb.executeFindQuery(body, args, sortAndPagination, whereClauses, havingClauses)
idsResult, countResult, err := qb.executeFindQuery(body, args, sortAndPagination, whereClauses, havingClauses, []string{})
if err != nil {
return nil, 0, err
}

View file

@ -1070,11 +1070,12 @@ func TestSceneQueryPerformerTags(t *testing.T) {
func TestSceneQueryStudio(t *testing.T) {
withTxn(func(r models.Repository) error {
sqb := r.Scene()
studioCriterion := models.MultiCriterionInput{
studioCriterion := models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithScene]),
},
Modifier: models.CriterionModifierIncludes,
Depth: 0,
}
sceneFilter := models.SceneFilterType{
@ -1088,11 +1089,12 @@ func TestSceneQueryStudio(t *testing.T) {
// ensure id is correct
assert.Equal(t, sceneIDs[sceneIdxWithStudio], scenes[0].ID)
studioCriterion = models.MultiCriterionInput{
studioCriterion = models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithScene]),
},
Modifier: models.CriterionModifierExcludes,
Depth: 0,
}
q := getSceneStringValue(sceneIdxWithStudio, titleField)
@ -1107,6 +1109,64 @@ func TestSceneQueryStudio(t *testing.T) {
})
}
func TestSceneQueryStudioDepth(t *testing.T) {
withTxn(func(r models.Repository) error {
sqb := r.Scene()
studioCriterion := models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithGrandChild]),
},
Modifier: models.CriterionModifierIncludes,
Depth: 2,
}
sceneFilter := models.SceneFilterType{
Studios: &studioCriterion,
}
scenes := queryScene(t, sqb, &sceneFilter, nil)
assert.Len(t, scenes, 1)
studioCriterion.Depth = 1
scenes = queryScene(t, sqb, &sceneFilter, nil)
assert.Len(t, scenes, 0)
studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}
scenes = queryScene(t, sqb, &sceneFilter, nil)
assert.Len(t, scenes, 1)
// ensure id is correct
assert.Equal(t, sceneIDs[sceneIdxWithGrandChildStudio], scenes[0].ID)
studioCriterion = models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithGrandChild]),
},
Modifier: models.CriterionModifierExcludes,
Depth: 2,
}
q := getSceneStringValue(sceneIdxWithGrandChildStudio, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
scenes = queryScene(t, sqb, &sceneFilter, &findFilter)
assert.Len(t, scenes, 0)
studioCriterion.Depth = 1
scenes = queryScene(t, sqb, &sceneFilter, &findFilter)
assert.Len(t, scenes, 1)
studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}
scenes = queryScene(t, sqb, &sceneFilter, &findFilter)
assert.Len(t, scenes, 0)
return nil
})
}
func TestSceneQueryMovies(t *testing.T) {
withTxn(func(r models.Repository) error {
sqb := r.Scene()

View file

@ -41,6 +41,7 @@ const (
sceneIdxWithPerformerTwoTags
sceneIdxWithSpacedName
sceneIdxWithStudioPerformer
sceneIdxWithGrandChildStudio
// new indexes above
lastSceneIdx
@ -65,6 +66,7 @@ const (
imageIdxInZip // TODO - not implemented
imageIdxWithPerformerTag
imageIdxWithPerformerTwoTags
imageIdxWithGrandChildStudio
// new indexes above
totalImages
)
@ -125,6 +127,7 @@ const (
galleryIdxWithPerformerTag
galleryIdxWithPerformerTwoTags
galleryIdxWithStudioPerformer
galleryIdxWithGrandChildStudio
// new indexes above
lastGalleryIdx
@ -169,6 +172,9 @@ const (
studioIdxWithScenePerformer
studioIdxWithImagePerformer
studioIdxWithGalleryPerformer
studioIdxWithGrandChild
studioIdxWithParentAndChild
studioIdxWithGrandParent
// new indexes above
// studios with dup names start from the end
studioIdxWithDupName
@ -241,6 +247,7 @@ var (
{sceneIdx1WithStudio, studioIdxWithTwoScenes},
{sceneIdx2WithStudio, studioIdxWithTwoScenes},
{sceneIdxWithStudioPerformer, studioIdxWithScenePerformer},
{sceneIdxWithGrandChildStudio, studioIdxWithGrandParent},
}
)
@ -257,6 +264,7 @@ var (
{imageIdx1WithStudio, studioIdxWithTwoImages},
{imageIdx2WithStudio, studioIdxWithTwoImages},
{imageIdxWithStudioPerformer, studioIdxWithImagePerformer},
{imageIdxWithGrandChildStudio, studioIdxWithGrandParent},
}
imageTagLinks = [][2]int{
{imageIdxWithTag, tagIdxWithImage},
@ -292,6 +300,7 @@ var (
{galleryIdx1WithStudio, studioIdxWithTwoGalleries},
{galleryIdx2WithStudio, studioIdxWithTwoGalleries},
{galleryIdxWithStudioPerformer, studioIdxWithGalleryPerformer},
{galleryIdxWithGrandChildStudio, studioIdxWithGrandParent},
}
galleryTagLinks = [][2]int{
@ -310,6 +319,8 @@ var (
var (
studioParentLinks = [][2]int{
{studioIdxWithChildStudio, studioIdxWithParentStudio},
{studioIdxWithGrandChild, studioIdxWithParentAndChild},
{studioIdxWithParentAndChild, studioIdxWithGrandParent},
}
)

View file

@ -359,9 +359,10 @@ func verifyStudiosImageCount(t *testing.T, imageCountCriterion models.IntCriteri
pp := 0
_, count, err := r.Image().Query(&models.ImageFilterType{
Studios: &models.MultiCriterionInput{
Studios: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(studio.ID)},
Modifier: models.CriterionModifierIncludes,
Depth: 0,
},
}, &models.FindFilterType{
PerPage: &pp,
@ -409,9 +410,10 @@ func verifyStudiosGalleryCount(t *testing.T, galleryCountCriterion models.IntCri
pp := 0
_, count, err := r.Gallery().Query(&models.GalleryFilterType{
Studios: &models.MultiCriterionInput{
Studios: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(studio.ID)},
Modifier: models.CriterionModifierIncludes,
Depth: 0,
},
}, &models.FindFilterType{
PerPage: &pp,

View file

@ -1,4 +1,5 @@
### ✨ New Features
* Support Studio filter including child studios. ([#1397](https://github.com/stashapp/stash/pull/1397))
* Added support for tag aliases. ([#1412](https://github.com/stashapp/stash/pull/1412))
* Support embedded Javascript plugins. ([#1393](https://github.com/stashapp/stash/pull/1393))
* Revamped job management: tasks can now be queued. ([#1379](https://github.com/stashapp/stash/pull/1379))

View file

@ -8,12 +8,16 @@ import {
DurationCriterion,
CriterionValue,
Criterion,
IHierarchicalLabeledIdCriterion,
} from "src/models/list-filter/criteria/criterion";
import { NoneCriterion } from "src/models/list-filter/criteria/none";
import { makeCriteria } from "src/models/list-filter/criteria/factory";
import { ListFilterOptions } from "src/models/list-filter/filter-options";
import { useIntl } from "react-intl";
import { CriterionType } from "src/models/list-filter/types";
import { defineMessages, useIntl } from "react-intl";
import {
criterionIsHierarchicalLabelValue,
CriterionType,
} from "src/models/list-filter/types";
interface IAddFilterProps {
onAddCriterion: (
@ -39,6 +43,13 @@ export const AddFilter: React.FC<IAddFilterProps> = (
const intl = useIntl();
const messages = defineMessages({
studio_depth: {
id: "studio_depth",
defaultMessage: "Levels (empty for all)",
},
});
// configure keyboard shortcuts
useEffect(() => {
Mousetrap.bind("f", () => setIsOpen(true));
@ -183,7 +194,29 @@ export const AddFilter: React.FC<IAddFilterProps> = (
/>
);
}
if (criterion.options) {
if (criterion instanceof IHierarchicalLabeledIdCriterion) {
if (criterion.criterionOption.value !== "studios") return;
return (
<FilterSelect
type={criterion.criterionOption.value}
isMulti
onSelect={(items) => {
const newCriterion = _.cloneDeep(criterion);
newCriterion.value.items = items.map((i) => ({
id: i.id,
label: i.name!,
}));
setCriterion(newCriterion);
}}
ids={criterion.value.items.map((labeled) => labeled.id)}
/>
);
}
if (
criterion.options &&
!criterionIsHierarchicalLabelValue(criterion.value)
) {
defaultValue.current = criterion.value;
return (
<Form.Control
@ -219,10 +252,53 @@ export const AddFilter: React.FC<IAddFilterProps> = (
/>
);
}
function renderAdditional() {
if (criterion instanceof IHierarchicalLabeledIdCriterion) {
return (
<>
<Form.Group>
<Form.Check
checked={criterion.value.depth !== 0}
label="Include child studios"
onChange={() => {
const newCriterion = _.cloneDeep(criterion);
newCriterion.value.depth =
newCriterion.value.depth !== 0 ? 0 : -1;
setCriterion(newCriterion);
}}
/>
</Form.Group>
{criterion.value.depth !== 0 && (
<Form.Group>
<Form.Control
className="btn-secondary"
type="number"
placeholder={intl.formatMessage(messages.studio_depth)}
onChange={(e) => {
const newCriterion = _.cloneDeep(criterion);
newCriterion.value.depth = e.target.value
? parseInt(e.target.value, 10)
: -1;
setCriterion(newCriterion);
}}
defaultValue={
criterion.value && criterion.value.depth !== -1
? criterion.value.depth
: ""
}
min="1"
/>
</Form.Group>
)}
</>
);
}
}
return (
<>
<Form.Group>{renderModifier()}</Form.Group>
<Form.Group>{renderSelect()}</Form.Group>
{renderAdditional()}
</>
);
};

View file

@ -12,9 +12,10 @@ export const StudioPerformersPanel: React.FC<IStudioPerformersPanel> = ({
studio,
}) => {
const studioCriterion = new StudiosCriterion();
studioCriterion.value = [
{ id: studio.id!, label: studio.name || `Studio ${studio.id}` },
];
studioCriterion.value = {
items: [{ id: studio.id!, label: studio.name || `Studio ${studio.id}` }],
depth: 0,
};
const extraCriteria = {
scenes: [studioCriterion],

View file

@ -17,18 +17,21 @@ export const studioFilterHook = (studio: Partial<GQL.StudioDataFragment>) => {
) {
// add the studio if not present
if (
!studioCriterion.value.find((p) => {
!studioCriterion.value.items.find((p) => {
return p.id === studio.id;
})
) {
studioCriterion.value.push(studioValue);
studioCriterion.value.items.push(studioValue);
}
studioCriterion.modifier = GQL.CriterionModifier.IncludesAll;
} else {
// overwrite
studioCriterion = new StudiosCriterion();
studioCriterion.value = [studioValue];
studioCriterion.value = {
items: [studioValue],
depth: 0,
};
filter.criteria.push(studioCriterion);
}

View file

@ -73,6 +73,7 @@
"scenes_updated_at": "Scene Updated At",
"seconds": "Seconds",
"stash_id": "Stash ID",
"studio_depth": "Levels (empty for all)",
"studios": "Studios",
"tag_count": "Tag Count",
"tags": "Tags",

View file

@ -3,6 +3,7 @@
import { IntlShape } from "react-intl";
import {
CriterionModifier,
HierarchicalMultiCriterionInput,
MultiCriterionInput,
} from "src/core/generated-graphql";
import DurationUtils from "src/utils/duration";
@ -12,10 +13,15 @@ import {
ILabeledId,
ILabeledValue,
IOptionType,
IHierarchicalLabelValue,
} from "../types";
type Option = string | number | IOptionType;
export type CriterionValue = string | number | ILabeledId[];
export type CriterionValue =
| string
| number
| ILabeledId[]
| IHierarchicalLabelValue;
// V = criterion value type
export abstract class Criterion<V extends CriterionValue> {
@ -305,6 +311,69 @@ export abstract class ILabeledIdCriterion extends Criterion<ILabeledId[]> {
}
}
export abstract class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabelValue> {
public modifier = CriterionModifier.IncludesAll;
public modifierOptions = [
Criterion.getModifierOption(CriterionModifier.IncludesAll),
Criterion.getModifierOption(CriterionModifier.Includes),
Criterion.getModifierOption(CriterionModifier.Excludes),
];
public options: IOptionType[] = [];
public value: IHierarchicalLabelValue = {
items: [],
depth: 0,
};
public encodeValue() {
return {
items: this.value.items.map((o) => {
return encodeILabeledId(o);
}),
depth: this.value.depth,
};
}
protected toCriterionInput(): HierarchicalMultiCriterionInput {
return {
value: this.value.items.map((v) => v.id),
modifier: this.modifier,
depth: this.value.depth,
};
}
public getLabelValue(): string {
const labels = this.value.items.map((v) => v.label).join(", ");
if (this.value.depth === 0) {
return labels;
}
return `${labels} (+${this.value.depth > 0 ? this.value.depth : "all"})`;
}
public toJSON() {
const encodedCriterion = {
type: this.criterionOption.value,
value: this.encodeValue(),
modifier: this.modifier,
};
return JSON.stringify(encodedCriterion);
}
constructor(type: CriterionOption, includeAll: boolean) {
super(type);
if (!includeAll) {
this.modifier = CriterionModifier.Includes;
this.modifierOptions = [
Criterion.getModifierOption(CriterionModifier.Includes),
Criterion.getModifierOption(CriterionModifier.Excludes),
];
}
}
}
export class MandatoryNumberCriterion extends NumberCriterion {
public modifierOptions = [
Criterion.getModifierOption(CriterionModifier.Equals),

View file

@ -1,15 +1,13 @@
import { CriterionOption, ILabeledIdCriterion } from "./criterion";
abstract class AbstractStudiosCriterion extends ILabeledIdCriterion {
constructor(type: CriterionOption) {
super(type, false);
}
}
import {
CriterionOption,
IHierarchicalLabeledIdCriterion,
ILabeledIdCriterion,
} from "./criterion";
export const StudiosCriterionOption = new CriterionOption("studios", "studios");
export class StudiosCriterion extends AbstractStudiosCriterion {
export class StudiosCriterion extends IHierarchicalLabeledIdCriterion {
constructor() {
super(StudiosCriterionOption);
super(StudiosCriterionOption, false);
}
}
@ -18,8 +16,8 @@ export const ParentStudiosCriterionOption = new CriterionOption(
"parent_studios",
"parents"
);
export class ParentStudiosCriterion extends AbstractStudiosCriterion {
export class ParentStudiosCriterion extends ILabeledIdCriterion {
constructor() {
super(ParentStudiosCriterionOption);
super(ParentStudiosCriterionOption, false);
}
}

View file

@ -29,6 +29,18 @@ export interface ILabeledValue {
value: string;
}
export interface IHierarchicalLabelValue {
items: ILabeledId[];
depth: number;
}
export function criterionIsHierarchicalLabelValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any
): value is IHierarchicalLabelValue {
return typeof value === "object" && "items" in value && "depth" in value;
}
export function encodeILabeledId(o: ILabeledId) {
// escape " and \ and by encoding to JSON so that it encodes to JSON correctly down the line
const adjustedLabel = JSON.stringify(o.label).slice(1, -1);

View file

@ -85,9 +85,10 @@ const makeStudioScenesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
if (!studio.id) return "#";
const filter = new ListFilterModel();
const criterion = new StudiosCriterion();
criterion.value = [
{ id: studio.id, label: studio.name || `Studio ${studio.id}` },
];
criterion.value = {
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
depth: 0,
};
filter.criteria.push(criterion);
return `/scenes?${filter.makeQueryParameters()}`;
};
@ -96,9 +97,10 @@ const makeStudioImagesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
if (!studio.id) return "#";
const filter = new ListFilterModel();
const criterion = new StudiosCriterion();
criterion.value = [
{ id: studio.id, label: studio.name || `Studio ${studio.id}` },
];
criterion.value = {
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
depth: 0,
};
filter.criteria.push(criterion);
return `/images?${filter.makeQueryParameters()}`;
};
@ -107,9 +109,10 @@ const makeStudioGalleriesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
if (!studio.id) return "#";
const filter = new ListFilterModel();
const criterion = new StudiosCriterion();
criterion.value = [
{ id: studio.id, label: studio.name || `Studio ${studio.id}` },
];
criterion.value = {
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
depth: 0,
};
filter.criteria.push(criterion);
return `/galleries?${filter.makeQueryParameters()}`;
};