diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 96387deed..10d614008 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -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! +} diff --git a/pkg/dlna/cds.go b/pkg/dlna/cds.go index 8371cbef9..dc45bb336 100644 --- a/pkg/dlna/cds.go +++ b/pkg/dlna/cds.go @@ -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, }, } diff --git a/pkg/gallery/query.go b/pkg/gallery/query.go index 6cae24321..a354f5aa5 100644 --- a/pkg/gallery/query.go +++ b/pkg/gallery/query.go @@ -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, }, } diff --git a/pkg/image/query.go b/pkg/image/query.go index 58e276632..b5c70738c 100644 --- a/pkg/image/query.go +++ b/pkg/image/query.go @@ -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, }, } diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index f3a2f057a..5c28d9146 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -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)) + } } } } diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index b366d301d..f16b9eebc 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -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) } diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index bcc149edf..9e409029d 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -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() diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 51cc0d945..254008f9c 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -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) } diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 70343f44f..cd225fc82 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -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 { diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index cec607c5f..919214f2c 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -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 diff --git a/pkg/sqlite/movies_test.go b/pkg/sqlite/movies_test.go index 5ba543798..d85f39fd9 100644 --- a/pkg/sqlite/movies_test.go +++ b/pkg/sqlite/movies_test.go @@ -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) diff --git a/pkg/sqlite/query.go b/pkg/sqlite/query.go index 4c549cdf1..60b594f14 100644 --- a/pkg/sqlite/query.go +++ b/pkg/sqlite/query.go @@ -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) } diff --git a/pkg/sqlite/repository.go b/pkg/sqlite/repository.go index ead770459..182d0223f 100644 --- a/pkg/sqlite/repository.go +++ b/pkg/sqlite/repository.go @@ -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) diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 8d13a7080..8997837af 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -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) } diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index 9c8e6e9bc..0ad3cda43 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -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 } diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index c0c36922f..fa43e53f9 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -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() diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 4f733af46..eb219ea70 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -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}, } ) diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index cf0fc5096..8c97e50e7 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -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, diff --git a/ui/v2.5/src/components/Changelog/versions/v080.md b/ui/v2.5/src/components/Changelog/versions/v080.md index ed99750b4..dffaadd0f 100644 --- a/ui/v2.5/src/components/Changelog/versions/v080.md +++ b/ui/v2.5/src/components/Changelog/versions/v080.md @@ -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)) diff --git a/ui/v2.5/src/components/List/AddFilter.tsx b/ui/v2.5/src/components/List/AddFilter.tsx index f9f5b5b74..058faf5d9 100644 --- a/ui/v2.5/src/components/List/AddFilter.tsx +++ b/ui/v2.5/src/components/List/AddFilter.tsx @@ -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 = ( 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 = ( /> ); } - if (criterion.options) { + if (criterion instanceof IHierarchicalLabeledIdCriterion) { + if (criterion.criterionOption.value !== "studios") return; + + return ( + { + 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 ( = ( /> ); } + function renderAdditional() { + if (criterion instanceof IHierarchicalLabeledIdCriterion) { + return ( + <> + + { + const newCriterion = _.cloneDeep(criterion); + newCriterion.value.depth = + newCriterion.value.depth !== 0 ? 0 : -1; + setCriterion(newCriterion); + }} + /> + + {criterion.value.depth !== 0 && ( + + { + 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" + /> + + )} + + ); + } + } return ( <> {renderModifier()} {renderSelect()} + {renderAdditional()} ); }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx index 1e83fe8a6..23c2ebf7b 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx @@ -12,9 +12,10 @@ export const StudioPerformersPanel: React.FC = ({ 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], diff --git a/ui/v2.5/src/core/studios.ts b/ui/v2.5/src/core/studios.ts index cb201b679..29a66dc4e 100644 --- a/ui/v2.5/src/core/studios.ts +++ b/ui/v2.5/src/core/studios.ts @@ -17,18 +17,21 @@ export const studioFilterHook = (studio: Partial) => { ) { // 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); } diff --git a/ui/v2.5/src/locale/en-GB.json b/ui/v2.5/src/locale/en-GB.json index da639defb..650ea7369 100644 --- a/ui/v2.5/src/locale/en-GB.json +++ b/ui/v2.5/src/locale/en-GB.json @@ -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", diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index b6a16fa9c..aa959ef0b 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -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 { @@ -305,6 +311,69 @@ export abstract class ILabeledIdCriterion extends Criterion { } } +export abstract class IHierarchicalLabeledIdCriterion extends Criterion { + 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), diff --git a/ui/v2.5/src/models/list-filter/criteria/studios.ts b/ui/v2.5/src/models/list-filter/criteria/studios.ts index db0cab963..e351b5837 100644 --- a/ui/v2.5/src/models/list-filter/criteria/studios.ts +++ b/ui/v2.5/src/models/list-filter/criteria/studios.ts @@ -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); } } diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 3fadf3715..e3f61f398 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -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); diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index 9cbdd441a..98fd885c1 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -85,9 +85,10 @@ const makeStudioScenesUrl = (studio: Partial) => { 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) => { 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) => { 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()}`; };