diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index ae51f1091..db4c6c426 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -47,7 +47,7 @@ input PerformerFilterType { measurements: StringCriterionInput """Filter by fake tits value""" fake_tits: StringCriterionInput - """Filter by career length""" + """Filter by career length""" career_length: StringCriterionInput """Filter by tattoos""" tattoos: StringCriterionInput @@ -61,6 +61,14 @@ input PerformerFilterType { is_missing: String """Filter to only include performers with these tags""" tags: MultiCriterionInput + """Filter by tag count""" + tag_count: IntCriterionInput + """Filter by scene count""" + scene_count: IntCriterionInput + """Filter by image count""" + image_count: IntCriterionInput + """Filter by gallery count""" + gallery_count: IntCriterionInput """Filter by StashID""" stash_id: String """Filter by url""" @@ -82,7 +90,7 @@ input SceneFilterType { AND: SceneFilterType OR: SceneFilterType NOT: SceneFilterType - + """Filter by path""" path: StringCriterionInput """Filter by rating""" @@ -105,10 +113,14 @@ input SceneFilterType { movies: MultiCriterionInput """Filter to only include scenes with these tags""" tags: MultiCriterionInput + """Filter by tag count""" + tag_count: IntCriterionInput """Filter to only include scenes with performers with these tags""" performer_tags: MultiCriterionInput """Filter to only include scenes with these performers""" performers: MultiCriterionInput + """Filter by performer count""" + performer_count: IntCriterionInput """Filter by StashID""" stash_id: String """Filter by url""" @@ -152,10 +164,14 @@ input GalleryFilterType { studios: MultiCriterionInput """Filter to only include galleries with these tags""" tags: MultiCriterionInput + """Filter by tag count""" + tag_count: IntCriterionInput """Filter to only include galleries with performers with these tags""" performer_tags: MultiCriterionInput """Filter to only include galleries with these performers""" performers: MultiCriterionInput + """Filter by performer count""" + performer_count: IntCriterionInput """Filter by number of images in this gallery""" image_count: IntCriterionInput """Filter by url""" @@ -203,10 +219,14 @@ input ImageFilterType { studios: MultiCriterionInput """Filter to only include images with these tags""" tags: MultiCriterionInput + """Filter by tag count""" + tag_count: IntCriterionInput """Filter to only include images with performers with these tags""" performer_tags: MultiCriterionInput """Filter to only include images with these performers""" performers: MultiCriterionInput + """Filter by performer count""" + performer_count: IntCriterionInput """Filter to only include images with these galleries""" galleries: MultiCriterionInput } diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index a781e70fc..db3a5b8d0 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -405,3 +405,23 @@ func (m *multiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionI } } } + +type countCriterionHandlerBuilder struct { + primaryTable string + joinTable string + primaryFK string +} + +func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInput) criterionHandlerFunc { + return func(f *filterBuilder) { + if criterion != nil { + clause, count := getCountCriterionClause(m.primaryTable, m.joinTable, m.primaryFK, *criterion) + + if count == 1 { + f.addWhere(clause, criterion.Value) + } else { + f.addWhere(clause) + } + } + } +} diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 77ddabc24..2fb59bcc0 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -239,6 +239,16 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi query.addHaving(havingClause) } + if tagCountFilter := galleryFilter.TagCount; tagCountFilter != nil { + clause, count := getCountCriterionClause(galleryTable, galleriesTagsTable, galleryIDColumn, *tagCountFilter) + + if count == 1 { + query.addArg(tagCountFilter.Value) + } + + query.addWhere(clause) + } + if performersFilter := galleryFilter.Performers; performersFilter != nil && len(performersFilter.Value) > 0 { for _, performerID := range performersFilter.Value { query.addArg(performerID) @@ -250,6 +260,16 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi query.addHaving(havingClause) } + if performerCountFilter := galleryFilter.PerformerCount; performerCountFilter != nil { + clause, count := getCountCriterionClause(galleryTable, performersGalleriesTable, galleryIDColumn, *performerCountFilter) + + if count == 1 { + query.addArg(performerCountFilter.Value) + } + + query.addWhere(clause) + } + if studiosFilter := galleryFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 { for _, studioID := range studiosFilter.Value { query.addArg(studioID) @@ -382,7 +402,15 @@ func (qb *galleryQueryBuilder) getGallerySort(findFilter *models.FindFilterType) sort = findFilter.GetSort("path") direction = findFilter.GetDirection() } - return getSort(sort, direction, "galleries") + + switch sort { + case "tag_count": + return getCountSort(galleryTable, galleriesTagsTable, galleryIDColumn, direction) + case "performer_count": + return getCountSort(galleryTable, performersGalleriesTable, galleryIDColumn, direction) + default: + return getSort(sort, direction, "galleries") + } } func (qb *galleryQueryBuilder) queryGallery(query string, args []interface{}) (*models.Gallery, error) { diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index a715d7d5c..999224bdc 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -630,6 +630,88 @@ func TestGalleryQueryPerformerTags(t *testing.T) { }) } +func TestGalleryQueryTagCount(t *testing.T) { + const tagCount = 1 + tagCountCriterion := models.IntCriterionInput{ + Value: tagCount, + Modifier: models.CriterionModifierEquals, + } + + verifyGalleriesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyGalleriesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyGalleriesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierLessThan + verifyGalleriesTagCount(t, tagCountCriterion) +} + +func verifyGalleriesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Gallery() + galleryFilter := models.GalleryFilterType{ + TagCount: &tagCountCriterion, + } + + galleries := queryGallery(t, sqb, &galleryFilter, nil) + assert.Greater(t, len(galleries), 0) + + for _, gallery := range galleries { + ids, err := sqb.GetTagIDs(gallery.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), tagCountCriterion) + } + + return nil + }) +} + +func TestGalleryQueryPerformerCount(t *testing.T) { + const performerCount = 1 + performerCountCriterion := models.IntCriterionInput{ + Value: performerCount, + Modifier: models.CriterionModifierEquals, + } + + verifyGalleriesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyGalleriesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyGalleriesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierLessThan + verifyGalleriesPerformerCount(t, performerCountCriterion) +} + +func verifyGalleriesPerformerCount(t *testing.T, performerCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Gallery() + galleryFilter := models.GalleryFilterType{ + PerformerCount: &performerCountCriterion, + } + + galleries := queryGallery(t, sqb, &galleryFilter, nil) + assert.Greater(t, len(galleries), 0) + + for _, gallery := range galleries { + ids, err := sqb.GetPerformerIDs(gallery.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), performerCountCriterion) + } + + return nil + }) +} + // TODO Count // TODO All // TODO Query diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index adb23004e..bf7815bb5 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -328,6 +328,16 @@ func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilt query.addHaving(havingClause) } + if tagCountFilter := imageFilter.TagCount; tagCountFilter != nil { + clause, count := getCountCriterionClause(imageTable, imagesTagsTable, imageIDColumn, *tagCountFilter) + + if count == 1 { + query.addArg(tagCountFilter.Value) + } + + query.addWhere(clause) + } + if galleriesFilter := imageFilter.Galleries; galleriesFilter != nil && len(galleriesFilter.Value) > 0 { for _, galleryID := range galleriesFilter.Value { query.addArg(galleryID) @@ -350,6 +360,16 @@ func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilt query.addHaving(havingClause) } + if performerCountFilter := imageFilter.PerformerCount; performerCountFilter != nil { + clause, count := getCountCriterionClause(imageTable, performersImagesTable, imageIDColumn, *performerCountFilter) + + if count == 1 { + query.addArg(performerCountFilter.Value) + } + + query.addWhere(clause) + } + if studiosFilter := imageFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 { for _, studioID := range studiosFilter.Value { query.addArg(studioID) @@ -412,7 +432,15 @@ func (qb *imageQueryBuilder) getImageSort(findFilter *models.FindFilterType) str } sort := findFilter.GetSort("title") direction := findFilter.GetDirection() - return getSort(sort, direction, "images") + + 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) { diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 36077739b..153162420 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -683,6 +683,88 @@ func TestImageQueryPerformerTags(t *testing.T) { }) } +func TestImageQueryTagCount(t *testing.T) { + const tagCount = 1 + tagCountCriterion := models.IntCriterionInput{ + Value: tagCount, + Modifier: models.CriterionModifierEquals, + } + + verifyImagesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyImagesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyImagesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierLessThan + verifyImagesTagCount(t, tagCountCriterion) +} + +func verifyImagesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Image() + imageFilter := models.ImageFilterType{ + TagCount: &tagCountCriterion, + } + + images := queryImages(t, sqb, &imageFilter, nil) + assert.Greater(t, len(images), 0) + + for _, image := range images { + ids, err := sqb.GetTagIDs(image.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), tagCountCriterion) + } + + return nil + }) +} + +func TestImageQueryPerformerCount(t *testing.T) { + const performerCount = 1 + performerCountCriterion := models.IntCriterionInput{ + Value: performerCount, + Modifier: models.CriterionModifierEquals, + } + + verifyImagesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyImagesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyImagesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierLessThan + verifyImagesPerformerCount(t, performerCountCriterion) +} + +func verifyImagesPerformerCount(t *testing.T, performerCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Image() + imageFilter := models.ImageFilterType{ + PerformerCount: &performerCountCriterion, + } + + images := queryImages(t, sqb, &imageFilter, nil) + assert.Greater(t, len(images), 0) + + for _, image := range images { + ids, err := sqb.GetPerformerIDs(image.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), performerCountCriterion) + } + + return nil + }) +} + func TestImageQuerySorting(t *testing.T) { withTxn(func(r models.Repository) error { sort := titleField diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 2daa22b89..fd744f983 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -271,6 +271,11 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy query.addHaving(havingClause) } + query.handleCountCriterion(performerFilter.TagCount, performerTable, performersTagsTable, performerIDColumn) + query.handleCountCriterion(performerFilter.SceneCount, performerTable, performersScenesTable, performerIDColumn) + query.handleCountCriterion(performerFilter.ImageCount, performerTable, performersImagesTable, performerIDColumn) + query.handleCountCriterion(performerFilter.GalleryCount, performerTable, performersGalleriesTable, performerIDColumn) + query.sortAndPagination = qb.getPerformerSort(findFilter) + getPagination(findFilter) idsResult, countResult, err := query.executeFind() if err != nil { @@ -370,6 +375,11 @@ func (qb *performerQueryBuilder) getPerformerSort(findFilter *models.FindFilterT sort = findFilter.GetSort("name") direction = findFilter.GetDirection() } + + if sort == "tag_count" { + return getCountSort(performerTable, performersTagsTable, performerIDColumn, direction) + } + return getSort(sort, direction, "performers") } diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index bf9024017..8fb1a7df3 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -387,6 +387,188 @@ func TestPerformerQueryTags(t *testing.T) { }) } +func TestPerformerQueryTagCount(t *testing.T) { + const tagCount = 1 + tagCountCriterion := models.IntCriterionInput{ + Value: tagCount, + Modifier: models.CriterionModifierEquals, + } + + verifyPerformersTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyPerformersTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyPerformersTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierLessThan + verifyPerformersTagCount(t, tagCountCriterion) +} + +func verifyPerformersTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Performer() + performerFilter := models.PerformerFilterType{ + TagCount: &tagCountCriterion, + } + + performers := queryPerformers(t, sqb, &performerFilter, nil) + assert.Greater(t, len(performers), 0) + + for _, performer := range performers { + ids, err := sqb.GetTagIDs(performer.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), tagCountCriterion) + } + + return nil + }) +} + +func TestPerformerQuerySceneCount(t *testing.T) { + const sceneCount = 1 + sceneCountCriterion := models.IntCriterionInput{ + Value: sceneCount, + Modifier: models.CriterionModifierEquals, + } + + verifyPerformersSceneCount(t, sceneCountCriterion) + + sceneCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyPerformersSceneCount(t, sceneCountCriterion) + + sceneCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyPerformersSceneCount(t, sceneCountCriterion) + + sceneCountCriterion.Modifier = models.CriterionModifierLessThan + verifyPerformersSceneCount(t, sceneCountCriterion) +} + +func verifyPerformersSceneCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Performer() + performerFilter := models.PerformerFilterType{ + SceneCount: &sceneCountCriterion, + } + + performers := queryPerformers(t, sqb, &performerFilter, nil) + assert.Greater(t, len(performers), 0) + + for _, performer := range performers { + ids, err := r.Scene().FindByPerformerID(performer.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), sceneCountCriterion) + } + + return nil + }) +} + +func TestPerformerQueryImageCount(t *testing.T) { + const imageCount = 1 + imageCountCriterion := models.IntCriterionInput{ + Value: imageCount, + Modifier: models.CriterionModifierEquals, + } + + verifyPerformersImageCount(t, imageCountCriterion) + + imageCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyPerformersImageCount(t, imageCountCriterion) + + imageCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyPerformersImageCount(t, imageCountCriterion) + + imageCountCriterion.Modifier = models.CriterionModifierLessThan + verifyPerformersImageCount(t, imageCountCriterion) +} + +func verifyPerformersImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Performer() + performerFilter := models.PerformerFilterType{ + ImageCount: &imageCountCriterion, + } + + performers := queryPerformers(t, sqb, &performerFilter, nil) + assert.Greater(t, len(performers), 0) + + for _, performer := range performers { + pp := 0 + + _, count, err := r.Image().Query(&models.ImageFilterType{ + Performers: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(performer.ID)}, + Modifier: models.CriterionModifierIncludes, + }, + }, &models.FindFilterType{ + PerPage: &pp, + }) + if err != nil { + return err + } + verifyInt(t, count, imageCountCriterion) + } + + return nil + }) +} + +func TestPerformerQueryGalleryCount(t *testing.T) { + const galleryCount = 1 + galleryCountCriterion := models.IntCriterionInput{ + Value: galleryCount, + Modifier: models.CriterionModifierEquals, + } + + verifyPerformersGalleryCount(t, galleryCountCriterion) + + galleryCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyPerformersGalleryCount(t, galleryCountCriterion) + + galleryCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyPerformersGalleryCount(t, galleryCountCriterion) + + galleryCountCriterion.Modifier = models.CriterionModifierLessThan + verifyPerformersGalleryCount(t, galleryCountCriterion) +} + +func verifyPerformersGalleryCount(t *testing.T, galleryCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Performer() + performerFilter := models.PerformerFilterType{ + GalleryCount: &galleryCountCriterion, + } + + performers := queryPerformers(t, sqb, &performerFilter, nil) + assert.Greater(t, len(performers), 0) + + for _, performer := range performers { + pp := 0 + + _, count, err := r.Gallery().Query(&models.GalleryFilterType{ + Performers: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(performer.ID)}, + Modifier: models.CriterionModifierIncludes, + }, + }, &models.FindFilterType{ + PerPage: &pp, + }) + if err != nil { + return err + } + verifyInt(t, count, galleryCountCriterion) + } + + return nil + }) +} + func TestPerformerStashIDs(t *testing.T) { if err := withTxn(func(r models.Repository) error { qb := r.Performer() diff --git a/pkg/sqlite/query.go b/pkg/sqlite/query.go index 82a17cf4f..cf4cf0b5f 100644 --- a/pkg/sqlite/query.go +++ b/pkg/sqlite/query.go @@ -151,3 +151,15 @@ func (qb *queryBuilder) handleStringCriterionInput(c *models.StringCriterionInpu } } } + +func (qb *queryBuilder) handleCountCriterion(countFilter *models.IntCriterionInput, primaryTable, joinTable, primaryFK string) { + if countFilter != nil { + clause, count := getCountCriterionClause(primaryTable, joinTable, primaryFK, *countFilter) + + if count == 1 { + qb.addArg(countFilter.Value) + } + + qb.addWhere(clause) + } +} diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 1cce0c344..ffd2f01c5 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -286,7 +286,7 @@ func (qb *sceneQueryBuilder) Wall(q *string) ([]*models.Scene, error) { } func (qb *sceneQueryBuilder) All() ([]*models.Scene, error) { - return qb.queryScenes(selectAll(sceneTable)+qb.getSceneSort(nil), nil) + return qb.queryScenes(selectAll(sceneTable)+qb.getDefaultSceneSort(), nil) } func illegalFilterCombination(type1, type2 string) error { @@ -348,7 +348,9 @@ func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *fi query.handleCriterionFunc(stringCriterionHandler(sceneFilter.URL, "scenes.url")) query.handleCriterionFunc(sceneTagsCriterionHandler(qb, sceneFilter.Tags)) + query.handleCriterionFunc(sceneTagCountCriterionHandler(qb, sceneFilter.TagCount)) query.handleCriterionFunc(scenePerformersCriterionHandler(qb, sceneFilter.Performers)) + query.handleCriterionFunc(scenePerformerCountCriterionHandler(qb, sceneFilter.PerformerCount)) query.handleCriterionFunc(sceneStudioCriterionHandler(qb, sceneFilter.Studios)) query.handleCriterionFunc(sceneMoviesCriterionHandler(qb, sceneFilter.Movies)) query.handleCriterionFunc(sceneStashIDsHandler(qb, sceneFilter.StashID)) @@ -384,7 +386,8 @@ func (qb *sceneQueryBuilder) Query(sceneFilter *models.SceneFilterType, findFilt query.addFilter(filter) - query.sortAndPagination = qb.getSceneSort(findFilter) + getPagination(findFilter) + qb.setSceneSort(&query, findFilter) + query.sortAndPagination += getPagination(findFilter) idsResult, countResult, err := query.executeFind() if err != nil { @@ -520,6 +523,7 @@ func (qb *sceneQueryBuilder) getMultiCriterionHandlerBuilder(foreignTable, joinT addJoinsFunc: addJoinsFunc, } } + func sceneTagsCriterionHandler(qb *sceneQueryBuilder, tags *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { qb.tagsRepository().join(f, "tags_join", "scenes.id") @@ -530,6 +534,16 @@ func sceneTagsCriterionHandler(qb *sceneQueryBuilder, tags *models.MultiCriterio return h.handler(tags) } +func sceneTagCountCriterionHandler(qb *sceneQueryBuilder, tagCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: scenesTagsTable, + primaryFK: sceneIDColumn, + } + + return h.handler(tagCount) +} + func scenePerformersCriterionHandler(qb *sceneQueryBuilder, performers *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { qb.performersRepository().join(f, "performers_join", "scenes.id") @@ -540,6 +554,16 @@ func scenePerformersCriterionHandler(qb *sceneQueryBuilder, performers *models.M return h.handler(performers) } +func scenePerformerCountCriterionHandler(qb *sceneQueryBuilder, performerCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: performersScenesTable, + primaryFK: sceneIDColumn, + } + + 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") @@ -586,8 +610,8 @@ func scenePerformerTagsCriterionHandler(qb *sceneQueryBuilder, performerTagsFilt 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_scenes.performer_id from performers_scenes + f.addWhere(fmt.Sprintf(`not exists + (select performers_scenes.performer_id from performers_scenes left join performers_tags on performers_tags.performer_id = performers_scenes.performer_id where performers_scenes.scene_id = scenes.id AND performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value))), args...) @@ -612,8 +636,8 @@ func handleScenePerformerTagsCriterion(query *queryBuilder, performerTagsFilter query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value))) query.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value))) } else if performerTagsFilter.Modifier == models.CriterionModifierExcludes { - query.addWhere(fmt.Sprintf(`not exists - (select performers_scenes.performer_id from performers_scenes + query.addWhere(fmt.Sprintf(`not exists + (select performers_scenes.performer_id from performers_scenes left join performers_tags on performers_tags.performer_id = performers_scenes.performer_id where performers_scenes.scene_id = scenes.id AND performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value)))) @@ -621,13 +645,25 @@ func handleScenePerformerTagsCriterion(query *queryBuilder, performerTagsFilter } } -func (qb *sceneQueryBuilder) getSceneSort(findFilter *models.FindFilterType) string { +func (qb *sceneQueryBuilder) getDefaultSceneSort() string { + return " ORDER BY scenes.path, scenes.date ASC " +} + +func (qb *sceneQueryBuilder) setSceneSort(query *queryBuilder, findFilter *models.FindFilterType) { if findFilter == nil { - return " ORDER BY scenes.path, scenes.date ASC " + query.sortAndPagination += qb.getDefaultSceneSort() + return } sort := findFilter.GetSort("title") direction := findFilter.GetDirection() - return getSort(sort, direction, "scenes") + switch sort { + case "tag_count": + query.sortAndPagination += getCountSort(sceneTable, scenesTagsTable, sceneIDColumn, direction) + case "performer_count": + query.sortAndPagination += getCountSort(sceneTable, performersScenesTable, sceneIDColumn, direction) + default: + query.sortAndPagination += getSort(sort, direction, "scenes") + } } func (qb *sceneQueryBuilder) queryScene(query string, args []interface{}) (*models.Scene, error) { diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 5f30ce4ac..cb4927523 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -1214,6 +1214,88 @@ func TestSceneQueryPagination(t *testing.T) { }) } +func TestSceneQueryTagCount(t *testing.T) { + const tagCount = 1 + tagCountCriterion := models.IntCriterionInput{ + Value: tagCount, + Modifier: models.CriterionModifierEquals, + } + + verifyScenesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyScenesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyScenesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierLessThan + verifyScenesTagCount(t, tagCountCriterion) +} + +func verifyScenesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Scene() + sceneFilter := models.SceneFilterType{ + TagCount: &tagCountCriterion, + } + + scenes := queryScene(t, sqb, &sceneFilter, nil) + assert.Greater(t, len(scenes), 0) + + for _, scene := range scenes { + ids, err := sqb.GetTagIDs(scene.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), tagCountCriterion) + } + + return nil + }) +} + +func TestSceneQueryPerformerCount(t *testing.T) { + const performerCount = 1 + performerCountCriterion := models.IntCriterionInput{ + Value: performerCount, + Modifier: models.CriterionModifierEquals, + } + + verifyScenesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyScenesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyScenesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierLessThan + verifyScenesPerformerCount(t, performerCountCriterion) +} + +func verifyScenesPerformerCount(t *testing.T, performerCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Scene() + sceneFilter := models.SceneFilterType{ + PerformerCount: &performerCountCriterion, + } + + scenes := queryScene(t, sqb, &sceneFilter, nil) + assert.Greater(t, len(scenes), 0) + + for _, scene := range scenes { + ids, err := sqb.GetPerformerIDs(scene.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), performerCountCriterion) + } + + return nil + }) +} + func TestSceneCountByTagID(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 f178bac09..de5930fd4 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -24,6 +24,8 @@ const ( sceneIdxWithMovie = iota sceneIdxWithGallery sceneIdxWithPerformer + sceneIdx1WithPerformer + sceneIdx2WithPerformer sceneIdxWithTwoPerformers sceneIdxWithTag sceneIdxWithTwoTags @@ -40,6 +42,8 @@ const ( const ( imageIdxWithGallery = iota imageIdxWithPerformer + imageIdx1WithPerformer + imageIdx2WithPerformer imageIdxWithTwoPerformers imageIdxWithTag imageIdxWithTwoTags @@ -55,12 +59,15 @@ const ( performerIdxWithScene = iota performerIdx1WithScene performerIdx2WithScene + performerIdxWithTwoScenes performerIdxWithImage + performerIdxWithTwoImages performerIdx1WithImage performerIdx2WithImage performerIdxWithTag performerIdxWithTwoTags performerIdxWithGallery + performerIdxWithTwoGalleries performerIdx1WithGallery performerIdx2WithGallery // new indexes above @@ -87,6 +94,8 @@ const ( galleryIdxWithScene = iota galleryIdxWithImage galleryIdxWithPerformer + galleryIdx1WithPerformer + galleryIdx2WithPerformer galleryIdxWithTwoPerformers galleryIdxWithTag galleryIdxWithTwoTags @@ -185,6 +194,8 @@ var ( {sceneIdxWithTwoPerformers, performerIdx2WithScene}, {sceneIdxWithPerformerTag, performerIdxWithTag}, {sceneIdxWithPerformerTwoTags, performerIdxWithTwoTags}, + {sceneIdx1WithPerformer, performerIdxWithTwoScenes}, + {sceneIdx2WithPerformer, performerIdxWithTwoScenes}, } sceneGalleryLinks = [][2]int{ @@ -218,6 +229,8 @@ var ( {imageIdxWithTwoPerformers, performerIdx2WithImage}, {imageIdxWithPerformerTag, performerIdxWithTag}, {imageIdxWithPerformerTwoTags, performerIdxWithTwoTags}, + {imageIdx1WithPerformer, performerIdxWithTwoImages}, + {imageIdx2WithPerformer, performerIdxWithTwoImages}, } ) @@ -228,6 +241,8 @@ var ( {galleryIdxWithTwoPerformers, performerIdx2WithGallery}, {galleryIdxWithPerformerTag, performerIdxWithTag}, {galleryIdxWithPerformerTwoTags, performerIdxWithTwoTags}, + {galleryIdx1WithPerformer, performerIdxWithTwoGalleries}, + {galleryIdx2WithPerformer, performerIdxWithTwoGalleries}, } galleryTagLinks = [][2]int{ diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 71b46fe80..bbd8fde13 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -2,6 +2,7 @@ package sqlite import ( "database/sql" + "fmt" "math/rand" "strconv" "strings" @@ -44,10 +45,15 @@ func getPaginationSQL(page int, perPage int) string { return " LIMIT " + strconv.Itoa(perPage) + " OFFSET " + strconv.Itoa(page) + " " } -func getSort(sort string, direction string, tableName string) string { +func getSortDirection(direction string) string { if direction != "ASC" && direction != "DESC" { - direction = "ASC" + return "ASC" + } else { + return direction } +} +func getSort(sort string, direction string, tableName string) string { + direction = getSortDirection(direction) const randomSeedPrefix = "random_" @@ -96,6 +102,10 @@ func getRandomSort(tableName string, direction string, seed float64) string { return " ORDER BY " + "(substr(" + colName + " * " + randomSortString + ", length(" + colName + ") + 2))" + " " + direction } +func getCountSort(primaryTable, joinTable, primaryFK, direction string) string { + return fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM %s WHERE %s = %s.id) %s", joinTable, primaryFK, primaryTable, getSortDirection(direction)) +} + func getSearchBinding(columns []string, q string, not bool) (string, []interface{}) { var likeClauses []string var args []interface{} @@ -213,6 +223,11 @@ func getMultiCriterionClause(primaryTable, foreignTable, joinTable, primaryFK, f return whereClause, havingClause } +func getCountCriterionClause(primaryTable, joinTable, primaryFK string, criterion models.IntCriterionInput) (string, int) { + lhs := fmt.Sprintf("(SELECT COUNT(*) FROM %s s WHERE s.%s = %s.id)", joinTable, primaryFK, primaryTable) + return getIntCriterionWhereClause(lhs, criterion) +} + func ensureTx(tx *sqlx.Tx) { if tx == nil { panic("must use a transaction") diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 35e591f1f..92eccf82a 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -3,6 +3,7 @@ * Added scene queue. ### 🎨 Improvements +* Add various `count` filter criteria and sort options. * Scroll to top when changing page number. * Add URL filter criteria for scenes, galleries, movies, performers and studios. * Add HTTP endpoint for health checking at `/healthz`. 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 f18a22366..ee351a3c5 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -25,6 +25,7 @@ export type CriterionType = | "tags" | "sceneTags" | "performerTags" + | "tag_count" | "performers" | "studios" | "movies" @@ -90,6 +91,8 @@ export abstract class Criterion { return "Scene Tags"; case "performerTags": return "Performer Tags"; + case "tag_count": + return "Tag Count"; case "performers": return "Performers"; case "studios": @@ -358,6 +361,15 @@ export class NumberCriterion extends Criterion { } } +export class MandatoryNumberCriterion extends NumberCriterion { + public modifierOptions = [ + Criterion.getModifierOption(CriterionModifier.Equals), + Criterion.getModifierOption(CriterionModifier.NotEquals), + Criterion.getModifierOption(CriterionModifier.GreaterThan), + Criterion.getModifierOption(CriterionModifier.LessThan), + ]; +} + export class DurationCriterion extends Criterion { public type: CriterionType; public parameterName: string; diff --git a/ui/v2.5/src/models/list-filter/criteria/utils.ts b/ui/v2.5/src/models/list-filter/criteria/utils.ts index 011574546..81d37e750 100644 --- a/ui/v2.5/src/models/list-filter/criteria/utils.ts +++ b/ui/v2.5/src/models/list-filter/criteria/utils.ts @@ -1,12 +1,11 @@ /* eslint-disable consistent-return, default-case */ -import { CriterionModifier } from "src/core/generated-graphql"; import { - Criterion, CriterionType, StringCriterion, NumberCriterion, DurationCriterion, MandatoryStringCriterion, + MandatoryNumberCriterion, } from "./criterion"; import { OrganizedCriterion } from "./organized"; import { FavoriteCriterion } from "./favorite"; @@ -46,7 +45,8 @@ export function makeCriteria(type: CriterionType = "none") { case "image_count": case "gallery_count": case "performer_count": - return new NumberCriterion(type, type); + case "tag_count": + return new MandatoryNumberCriterion(type, type); case "resolution": return new ResolutionCriterion(); case "average_resolution": @@ -89,17 +89,8 @@ export function makeCriteria(type: CriterionType = "none") { return new GalleriesCriterion(); case "birth_year": return new NumberCriterion(type, type); - case "age": { - const ret = new NumberCriterion(type, type); - // null/not null doesn't make sense for these criteria - ret.modifierOptions = [ - Criterion.getModifierOption(CriterionModifier.Equals), - Criterion.getModifierOption(CriterionModifier.NotEquals), - Criterion.getModifierOption(CriterionModifier.GreaterThan), - Criterion.getModifierOption(CriterionModifier.LessThan), - ]; - return ret; - } + case "age": + return new MandatoryNumberCriterion(type, type); case "gender": return new GenderCriterion(); case "ethnicity": diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 5ede1dee1..58116771b 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -128,6 +128,8 @@ export class ListFilterModel { "duration", "framerate", "bitrate", + "tag_count", + "performer_count", "random", ]; this.displayModeOptions = [ @@ -147,8 +149,10 @@ export class ListFilterModel { new HasMarkersCriterionOption(), new SceneIsMissingCriterionOption(), new TagsCriterionOption(), + ListFilterModel.createCriterionOption("tag_count"), new PerformerTagsCriterionOption(), new PerformersCriterionOption(), + ListFilterModel.createCriterionOption("performer_count"), new StudiosCriterionOption(), new MoviesCriterionOption(), ListFilterModel.createCriterionOption("url"), @@ -163,6 +167,8 @@ export class ListFilterModel { "o_counter", "filesize", "file_mod_time", + "tag_count", + "performer_count", "random", ]; this.displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall]; @@ -175,8 +181,10 @@ export class ListFilterModel { new ResolutionCriterionOption(), new ImageIsMissingCriterionOption(), new TagsCriterionOption(), + ListFilterModel.createCriterionOption("tag_count"), new PerformerTagsCriterionOption(), new PerformersCriterionOption(), + ListFilterModel.createCriterionOption("performer_count"), new StudiosCriterionOption(), ]; break; @@ -187,6 +195,7 @@ export class ListFilterModel { "height", "birthdate", "scenes_count", + "tag_count", "random", ]; this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; @@ -212,6 +221,10 @@ export class ListFilterModel { new PerformerIsMissingCriterionOption(), new TagsCriterionOption(), ListFilterModel.createCriterionOption("url"), + ListFilterModel.createCriterionOption("tag_count"), + ListFilterModel.createCriterionOption("scene_count"), + ListFilterModel.createCriterionOption("image_count"), + ListFilterModel.createCriterionOption("gallery_count"), ...numberCriteria .concat(stringCriteria) .map((c) => ListFilterModel.createCriterionOption(c)), @@ -247,6 +260,8 @@ export class ListFilterModel { "path", "file_mod_time", "images_count", + "tag_count", + "performer_count", "random", ]; this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; @@ -258,8 +273,10 @@ export class ListFilterModel { new AverageResolutionCriterionOption(), new GalleryIsMissingCriterionOption(), new TagsCriterionOption(), + ListFilterModel.createCriterionOption("tag_count"), new PerformerTagsCriterionOption(), new PerformersCriterionOption(), + ListFilterModel.createCriterionOption("performer_count"), new StudiosCriterionOption(), ListFilterModel.createCriterionOption("url"), ]; @@ -563,6 +580,14 @@ export class ListFilterModel { }; break; } + case "tag_count": { + const tagCountCrit = criterion as NumberCriterion; + result.tag_count = { + value: tagCountCrit.value, + modifier: tagCountCrit.modifier, + }; + break; + } case "performers": { const perfCrit = criterion as PerformersCriterion; result.performers = { @@ -571,6 +596,14 @@ export class ListFilterModel { }; break; } + case "performer_count": { + const performerCountCrit = criterion as NumberCriterion; + result.performer_count = { + value: performerCountCrit.value, + modifier: performerCountCrit.modifier, + }; + break; + } case "studios": { const studCrit = criterion as StudiosCriterion; result.studios = { @@ -711,9 +744,42 @@ export class ListFilterModel { }; break; } + case "tag_count": { + const tagCountCrit = criterion as NumberCriterion; + result.tag_count = { + value: tagCountCrit.value, + modifier: tagCountCrit.modifier, + }; + break; + } + case "scene_count": { + const countCrit = criterion as NumberCriterion; + result.scene_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } + case "image_count": { + const countCrit = criterion as NumberCriterion; + result.image_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } + case "gallery_count": { + const countCrit = criterion as NumberCriterion; + result.gallery_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } // no default } }); + return result; } @@ -839,6 +905,14 @@ export class ListFilterModel { }; break; } + case "tag_count": { + const tagCountCrit = criterion as NumberCriterion; + result.tag_count = { + value: tagCountCrit.value, + modifier: tagCountCrit.modifier, + }; + break; + } case "performerTags": { const performerTagsCrit = criterion as TagsCriterion; result.performer_tags = { @@ -855,6 +929,14 @@ export class ListFilterModel { }; break; } + case "performer_count": { + const countCrit = criterion as NumberCriterion; + result.performer_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } case "studios": { const studCrit = criterion as StudiosCriterion; result.studios = { @@ -1014,6 +1096,14 @@ export class ListFilterModel { }; break; } + case "tag_count": { + const tagCountCrit = criterion as NumberCriterion; + result.tag_count = { + value: tagCountCrit.value, + modifier: tagCountCrit.modifier, + }; + break; + } case "performerTags": { const performerTagsCrit = criterion as TagsCriterion; result.performer_tags = { @@ -1030,6 +1120,14 @@ export class ListFilterModel { }; break; } + case "performer_count": { + const countCrit = criterion as NumberCriterion; + result.performer_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } case "studios": { const studCrit = criterion as StudiosCriterion; result.studios = {