From 25311247ed8ae3255c8ed5de3c8ff30929272d6b Mon Sep 17 00:00:00 2001 From: julien0221 <68500525+julien0221@users.noreply.github.com> Date: Fri, 9 Apr 2021 06:05:11 +0100 Subject: [PATCH] added an url filter option in scenes (#1266) * added an url filter option in scenes * added url filter on gallery, movies, performers and studios * Add empty string filter to stringCriterionHandler * Add unit tests Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/types/filters.graphql | 10 +++ pkg/sqlite/filter.go | 4 + pkg/sqlite/filter_internal_test.go | 4 +- pkg/sqlite/gallery.go | 1 + pkg/sqlite/gallery_test.go | 56 ++++++++++++++ pkg/sqlite/movies.go | 39 +++++----- pkg/sqlite/movies_test.go | 65 ++++++++++++++++ pkg/sqlite/performer.go | 1 + pkg/sqlite/performer_test.go | 56 ++++++++++++++ pkg/sqlite/scene.go | 1 + pkg/sqlite/scene_test.go | 61 +++++++++++++++ pkg/sqlite/setup_test.go | 75 ++++++++++++++++--- pkg/sqlite/studio.go | 40 +++++----- pkg/sqlite/studio_test.go | 65 ++++++++++++++++ .../src/components/Changelog/versions/v070.md | 3 +- .../models/list-filter/criteria/criterion.ts | 5 +- .../src/models/list-filter/criteria/utils.ts | 1 + ui/v2.5/src/models/list-filter/filter.ts | 45 +++++++++++ 18 files changed, 480 insertions(+), 52 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 750cb6c89..ae51f1091 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -63,6 +63,8 @@ input PerformerFilterType { tags: MultiCriterionInput """Filter by StashID""" stash_id: String + """Filter by url""" + url: StringCriterionInput } input SceneMarkerFilterType { @@ -109,6 +111,8 @@ input SceneFilterType { performers: MultiCriterionInput """Filter by StashID""" stash_id: String + """Filter by url""" + url: StringCriterionInput } input MovieFilterType { @@ -116,6 +120,8 @@ input MovieFilterType { studios: MultiCriterionInput """Filter to only include movies missing this property""" is_missing: String + """Filter by url""" + url: StringCriterionInput } input StudioFilterType { @@ -125,6 +131,8 @@ input StudioFilterType { stash_id: String """Filter to only include studios missing this property""" is_missing: String + """Filter by url""" + url: StringCriterionInput } input GalleryFilterType { @@ -150,6 +158,8 @@ input GalleryFilterType { performers: MultiCriterionInput """Filter by number of images in this gallery""" image_count: IntCriterionInput + """Filter by url""" + url: StringCriterionInput } input TagFilterType { diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index bae383825..a781e70fc 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -321,6 +321,10 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite return } f.addWhere(fmt.Sprintf("(%s IS NULL OR %[1]s NOT regexp ?)", column), c.Value) + case models.CriterionModifierIsNull: + f.addWhere("(" + column + " IS NULL OR TRIM(" + column + ") = '')") + case models.CriterionModifierNotNull: + f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')") default: clause, count := getSimpleCriterionClause(modifier, "?") diff --git a/pkg/sqlite/filter_internal_test.go b/pkg/sqlite/filter_internal_test.go index 302aff0db..20ca571e1 100644 --- a/pkg/sqlite/filter_internal_test.go +++ b/pkg/sqlite/filter_internal_test.go @@ -594,7 +594,7 @@ func TestStringCriterionHandlerIsNull(t *testing.T) { }, column)) assert.Len(f.whereClauses, 1) - assert.Equal(fmt.Sprintf("%[1]s IS NULL", column), f.whereClauses[0].sql) + assert.Equal(fmt.Sprintf("(%[1]s IS NULL OR TRIM(%[1]s) = '')", column), f.whereClauses[0].sql) assert.Len(f.whereClauses[0].args, 0) } @@ -609,6 +609,6 @@ func TestStringCriterionHandlerNotNull(t *testing.T) { }, column)) assert.Len(f.whereClauses, 1) - assert.Equal(fmt.Sprintf("%[1]s IS NOT NULL", column), f.whereClauses[0].sql) + assert.Equal(fmt.Sprintf("(%[1]s IS NOT NULL AND TRIM(%[1]s) != '')", column), f.whereClauses[0].sql) assert.Len(f.whereClauses[0].args, 0) } diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 2f2e8fef7..77ddabc24 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -198,6 +198,7 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi query.handleStringCriterionInput(galleryFilter.Path, "galleries.path") query.handleIntCriterionInput(galleryFilter.Rating, "galleries.rating") + query.handleStringCriterionInput(galleryFilter.URL, "galleries.url") qb.handleAverageResolutionFilter(&query, galleryFilter.AverageResolution) if Organized := galleryFilter.Organized; Organized != nil { diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index f34328ecd..a715d7d5c 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -193,6 +193,62 @@ func verifyGalleriesPath(t *testing.T, sqb models.GalleryReader, pathCriterion m } } +func TestGalleryQueryURL(t *testing.T) { + const sceneIdx = 1 + galleryURL := getGalleryStringValue(sceneIdx, urlField) + + urlCriterion := models.StringCriterionInput{ + Value: galleryURL, + Modifier: models.CriterionModifierEquals, + } + + filter := models.GalleryFilterType{ + URL: &urlCriterion, + } + + verifyFn := func(g *models.Gallery) { + t.Helper() + verifyNullString(t, g.URL, urlCriterion) + } + + verifyGalleryQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotEquals + verifyGalleryQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierMatchesRegex + urlCriterion.Value = "gallery_.*1_URL" + verifyGalleryQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex + verifyGalleryQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierIsNull + urlCriterion.Value = "" + verifyGalleryQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotNull + verifyGalleryQuery(t, filter, verifyFn) +} + +func verifyGalleryQuery(t *testing.T, filter models.GalleryFilterType, verifyFn func(s *models.Gallery)) { + withTxn(func(r models.Repository) error { + t.Helper() + sqb := r.Gallery() + + galleries := queryGallery(t, sqb, &filter, nil) + + // assume it should find at least one + assert.Greater(t, len(galleries), 0) + + for _, gallery := range galleries { + verifyFn(gallery) + } + + return nil + }) +} + func TestGalleryQueryRating(t *testing.T) { const rating = 3 ratingCriterion := models.IntCriterionInput{ diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index 567c8c1b7..cec607c5f 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -122,11 +122,10 @@ func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilt movieFilter = &models.MovieFilterType{} } - var whereClauses []string - var havingClauses []string - var args []interface{} - body := selectDistinctIDs("movies") - body += ` + 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 @@ -135,41 +134,43 @@ func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilt if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"movies.name"} clause, thisArgs := getSearchBinding(searchColumns, *q, false) - whereClauses = append(whereClauses, clause) - args = append(args, thisArgs...) + query.addWhere(clause) + query.addArg(thisArgs...) } if studiosFilter := movieFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 { for _, studioID := range studiosFilter.Value { - args = append(args, studioID) + query.addArg(studioID) } whereClause, havingClause := getMultiCriterionClause("movies", "studio", "", "", "studio_id", studiosFilter) - whereClauses = appendClause(whereClauses, whereClause) - havingClauses = appendClause(havingClauses, havingClause) + query.addWhere(whereClause) + query.addHaving(havingClause) } if isMissingFilter := movieFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { switch *isMissingFilter { case "front_image": - body += `left join movies_images on movies_images.movie_id = movies.id + query.body += `left join movies_images on movies_images.movie_id = movies.id ` - whereClauses = appendClause(whereClauses, "movies_images.front_image IS NULL") + query.addWhere("movies_images.front_image IS NULL") case "back_image": - body += `left join movies_images on movies_images.movie_id = movies.id + query.body += `left join movies_images on movies_images.movie_id = movies.id ` - whereClauses = appendClause(whereClauses, "movies_images.back_image IS NULL") + query.addWhere("movies_images.back_image IS NULL") case "scenes": - body += `left join movies_scenes on movies_scenes.movie_id = movies.id + query.body += `left join movies_scenes on movies_scenes.movie_id = movies.id ` - whereClauses = appendClause(whereClauses, "movies_scenes.scene_id IS NULL") + query.addWhere("movies_scenes.scene_id IS NULL") default: - whereClauses = appendClause(whereClauses, "movies."+*isMissingFilter+" IS NULL") + query.addWhere("movies." + *isMissingFilter + " IS NULL") } } - sortAndPagination := qb.getMovieSort(findFilter) + getPagination(findFilter) - idsResult, countResult, err := qb.executeFindQuery(body, args, sortAndPagination, whereClauses, havingClauses) + query.handleStringCriterionInput(movieFilter.URL, "movies.url") + + query.sortAndPagination = qb.getMovieSort(findFilter) + getPagination(findFilter) + idsResult, countResult, err := query.executeFind() if err != nil { return nil, 0, err } diff --git a/pkg/sqlite/movies_test.go b/pkg/sqlite/movies_test.go index 235f0184c..5ba543798 100644 --- a/pkg/sqlite/movies_test.go +++ b/pkg/sqlite/movies_test.go @@ -119,6 +119,71 @@ func TestMovieQueryStudio(t *testing.T) { }) } +func TestMovieQueryURL(t *testing.T) { + const sceneIdx = 1 + movieURL := getMovieStringValue(sceneIdx, urlField) + + urlCriterion := models.StringCriterionInput{ + Value: movieURL, + Modifier: models.CriterionModifierEquals, + } + + filter := models.MovieFilterType{ + URL: &urlCriterion, + } + + verifyFn := func(n *models.Movie) { + t.Helper() + verifyNullString(t, n.URL, urlCriterion) + } + + verifyMovieQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotEquals + verifyMovieQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierMatchesRegex + urlCriterion.Value = "movie_.*1_URL" + verifyMovieQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex + verifyMovieQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierIsNull + urlCriterion.Value = "" + verifyMovieQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotNull + verifyMovieQuery(t, filter, verifyFn) +} + +func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func(s *models.Movie)) { + withTxn(func(r models.Repository) error { + t.Helper() + sqb := r.Movie() + + movies := queryMovie(t, sqb, &filter, nil) + + // assume it should find at least one + assert.Greater(t, len(movies), 0) + + for _, m := range movies { + verifyFn(m) + } + + return nil + }) +} + +func queryMovie(t *testing.T, sqb models.MovieReader, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) []*models.Movie { + movies, _, err := sqb.Query(movieFilter, findFilter) + if err != nil { + t.Errorf("Error querying movie: %s", err.Error()) + } + + return movies +} + func TestMovieUpdateMovieImages(t *testing.T) { if err := withTxn(func(r models.Repository) error { mqb := r.Movie() diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 342fe8c63..2daa22b89 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -254,6 +254,7 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy query.handleStringCriterionInput(performerFilter.CareerLength, tableName+".career_length") query.handleStringCriterionInput(performerFilter.Tattoos, tableName+".tattoos") query.handleStringCriterionInput(performerFilter.Piercings, tableName+".piercings") + query.handleStringCriterionInput(performerFilter.URL, tableName+".url") // TODO - need better handling of aliases query.handleStringCriterionInput(performerFilter.Aliases, tableName+".aliases") diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index c5c7dc6cd..bf9024017 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -268,6 +268,62 @@ func verifyPerformerCareerLength(t *testing.T, criterion models.StringCriterionI }) } +func TestPerformerQueryURL(t *testing.T) { + const sceneIdx = 1 + performerURL := getPerformerStringValue(sceneIdx, urlField) + + urlCriterion := models.StringCriterionInput{ + Value: performerURL, + Modifier: models.CriterionModifierEquals, + } + + filter := models.PerformerFilterType{ + URL: &urlCriterion, + } + + verifyFn := func(g *models.Performer) { + t.Helper() + verifyNullString(t, g.URL, urlCriterion) + } + + verifyPerformerQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotEquals + verifyPerformerQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierMatchesRegex + urlCriterion.Value = "performer_.*1_URL" + verifyPerformerQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex + verifyPerformerQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierIsNull + urlCriterion.Value = "" + verifyPerformerQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotNull + verifyPerformerQuery(t, filter, verifyFn) +} + +func verifyPerformerQuery(t *testing.T, filter models.PerformerFilterType, verifyFn func(s *models.Performer)) { + withTxn(func(r models.Repository) error { + t.Helper() + sqb := r.Performer() + + performers := queryPerformers(t, sqb, &filter, nil) + + // assume it should find at least one + assert.Greater(t, len(performers), 0) + + for _, p := range performers { + verifyFn(p) + } + + return nil + }) +} + func queryPerformers(t *testing.T, qb models.PerformerReader, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) []*models.Performer { performers, _, err := qb.Query(performerFilter, findFilter) if err != nil { diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 8dd954322..1cce0c344 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -345,6 +345,7 @@ func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *fi query.handleCriterionFunc(resolutionCriterionHandler(sceneFilter.Resolution, "scenes.height", "scenes.width")) query.handleCriterionFunc(hasMarkersCriterionHandler(sceneFilter.HasMarkers)) query.handleCriterionFunc(sceneIsMissingCriterionHandler(qb, sceneFilter.IsMissing)) + query.handleCriterionFunc(stringCriterionHandler(sceneFilter.URL, "scenes.url")) query.handleCriterionFunc(sceneTagsCriterionHandler(qb, sceneFilter.Tags)) query.handleCriterionFunc(scenePerformersCriterionHandler(qb, sceneFilter.Performers)) diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index ac34403fb..5f30ce4ac 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -187,6 +187,44 @@ func TestSceneQueryPath(t *testing.T) { verifyScenesPath(t, pathCriterion) } +func TestSceneQueryURL(t *testing.T) { + const sceneIdx = 1 + scenePath := getSceneStringValue(sceneIdx, urlField) + + urlCriterion := models.StringCriterionInput{ + Value: scenePath, + Modifier: models.CriterionModifierEquals, + } + + filter := models.SceneFilterType{ + URL: &urlCriterion, + } + + verifyFn := func(s *models.Scene) { + t.Helper() + verifyNullString(t, s.URL, urlCriterion) + } + + verifySceneQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotEquals + verifySceneQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierMatchesRegex + urlCriterion.Value = "scene_.*1_URL" + verifySceneQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex + verifySceneQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierIsNull + urlCriterion.Value = "" + verifySceneQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotNull + verifySceneQuery(t, filter, verifyFn) +} + func TestSceneQueryPathOr(t *testing.T) { const scene1Idx = 1 const scene2Idx = 2 @@ -324,6 +362,24 @@ func TestSceneIllegalQuery(t *testing.T) { }) } +func verifySceneQuery(t *testing.T, filter models.SceneFilterType, verifyFn func(s *models.Scene)) { + withTxn(func(r models.Repository) error { + t.Helper() + sqb := r.Scene() + + scenes := queryScene(t, sqb, &filter, nil) + + // assume it should find at least one + assert.Greater(t, len(scenes), 0) + + for _, scene := range scenes { + verifyFn(scene) + } + + return nil + }) +} + func verifyScenesPath(t *testing.T, pathCriterion models.StringCriterionInput) { withTxn(func(r models.Repository) error { sqb := r.Scene() @@ -345,10 +401,15 @@ func verifyNullString(t *testing.T, value sql.NullString, criterion models.Strin t.Helper() assert := assert.New(t) if criterion.Modifier == models.CriterionModifierIsNull { + if value.Valid && value.String == "" { + // correct + return + } assert.False(value.Valid, "expect is null values to be null") } if criterion.Modifier == models.CriterionModifierNotNull { assert.True(value.Valid, "expect is null values to be null") + assert.Greater(len(value.String), 0) } if criterion.Modifier == models.CriterionModifierEquals { assert.Equal(criterion.Value, value.String) diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 323bb113c..f178bac09 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -76,7 +76,8 @@ const ( movieIdxWithScene = iota movieIdxWithStudio // movies with dup names start from the end - movieIdxWithDupName + // create 10 more basic movies (can remove this if we add more indexes) + movieIdxWithDupName = movieIdxWithStudio + 10 moviesNameCase = movieIdxWithDupName moviesNameNoCase = 1 @@ -146,6 +147,7 @@ const ( pathField = "Path" checksumField = "Checksum" titleField = "Title" + urlField = "URL" zipPath = "zipPath.zip" ) @@ -407,8 +409,32 @@ func populateDB() error { return nil } +func getPrefixedStringValue(prefix string, index int, field string) string { + return fmt.Sprintf("%s_%04d_%s", prefix, index, field) +} + +func getPrefixedNullStringValue(prefix string, index int, field string) sql.NullString { + if index > 0 && index%5 == 0 { + return sql.NullString{} + } + if index > 0 && index%6 == 0 { + return sql.NullString{ + String: "", + Valid: true, + } + } + return sql.NullString{ + String: getPrefixedStringValue(prefix, index, field), + Valid: true, + } +} + func getSceneStringValue(index int, field string) string { - return fmt.Sprintf("scene_%04d_%s", index, field) + return getPrefixedStringValue("scene", index, field) +} + +func getSceneNullStringValue(index int, field string) sql.NullString { + return getPrefixedNullStringValue("scene", index, field) } func getRating(index int) sql.NullInt64 { @@ -455,6 +481,7 @@ func createScenes(sqb models.SceneReaderWriter, n int) error { Title: sql.NullString{String: getSceneStringValue(i, titleField), Valid: true}, Checksum: sql.NullString{String: getSceneStringValue(i, checksumField), Valid: true}, Details: sql.NullString{String: getSceneStringValue(i, "Details"), Valid: true}, + URL: getSceneNullStringValue(i, urlField), Rating: getRating(i), OCounter: getOCounter(i), Duration: getSceneDuration(i), @@ -511,13 +538,18 @@ func createImages(qb models.ImageReaderWriter, n int) error { } func getGalleryStringValue(index int, field string) string { - return "gallery_" + strconv.FormatInt(int64(index), 10) + "_" + field + return getPrefixedStringValue("gallery", index, field) +} + +func getGalleryNullStringValue(index int, field string) sql.NullString { + return getPrefixedNullStringValue("gallery", index, field) } func createGalleries(gqb models.GalleryReaderWriter, n int) error { for i := 0; i < n; i++ { gallery := models.Gallery{ Path: models.NullString(getGalleryStringValue(i, pathField)), + URL: getGalleryNullStringValue(i, urlField), Checksum: getGalleryStringValue(i, checksumField), } @@ -534,10 +566,14 @@ func createGalleries(gqb models.GalleryReaderWriter, n int) error { } func getMovieStringValue(index int, field string) string { - return "movie_" + strconv.FormatInt(int64(index), 10) + "_" + field + return getPrefixedStringValue("movie", index, field) } -//createMoviees creates n movies with plain Name and o movies with camel cased NaMe included +func getMovieNullStringValue(index int, field string) sql.NullString { + return getPrefixedNullStringValue("movie", index, field) +} + +// createMoviees creates n movies with plain Name and o movies with camel cased NaMe included func createMovies(mqb models.MovieReaderWriter, n int, o int) error { const namePlain = "Name" const nameNoCase = "NaMe" @@ -555,6 +591,7 @@ func createMovies(mqb models.MovieReaderWriter, n int, o int) error { name = getMovieStringValue(index, name) movie := models.Movie{ Name: sql.NullString{String: name, Valid: true}, + URL: getMovieNullStringValue(index, urlField), Checksum: utils.MD5FromString(name), } @@ -572,7 +609,11 @@ func createMovies(mqb models.MovieReaderWriter, n int, o int) error { } func getPerformerStringValue(index int, field string) string { - return "performer_" + strconv.FormatInt(int64(index), 10) + "_" + field + return getPrefixedStringValue("performer", index, field) +} + +func getPerformerNullStringValue(index int, field string) sql.NullString { + return getPrefixedNullStringValue("performer", index, field) } func getPerformerBoolValue(index int) bool { @@ -596,7 +637,7 @@ func getPerformerCareerLength(index int) *string { return &ret } -//createPerformers creates n performers with plain Name and o performers with camel cased NaMe included +// createPerformers creates n performers with plain Name and o performers with camel cased NaMe included func createPerformers(pqb models.PerformerReaderWriter, n int, o int) error { const namePlain = "Name" const nameNoCase = "NaMe" @@ -615,6 +656,7 @@ func createPerformers(pqb models.PerformerReaderWriter, n int, o int) error { performer := models.Performer{ Name: sql.NullString{String: getPerformerStringValue(index, name), Valid: true}, Checksum: getPerformerStringValue(i, checksumField), + URL: getPerformerNullStringValue(i, urlField), Favorite: sql.NullBool{Bool: getPerformerBoolValue(i), Valid: true}, Birthdate: models.SQLiteDate{ String: getPerformerBirthdate(i), @@ -718,7 +760,11 @@ func createTags(tqb models.TagReaderWriter, n int, o int) error { } func getStudioStringValue(index int, field string) string { - return "studio_" + strconv.FormatInt(int64(index), 10) + "_" + field + return getPrefixedStringValue("studio", index, field) +} + +func getStudioNullStringValue(index int, field string) sql.NullString { + return getPrefixedNullStringValue("studio", index, field) } func createStudio(sqb models.StudioReaderWriter, name string, parentID *int64) (*models.Studio, error) { @@ -731,6 +777,10 @@ func createStudio(sqb models.StudioReaderWriter, name string, parentID *int64) ( studio.ParentID = sql.NullInt64{Int64: *parentID, Valid: true} } + return createStudioFromModel(sqb, studio) +} + +func createStudioFromModel(sqb models.StudioReaderWriter, studio models.Studio) (*models.Studio, error) { created, err := sqb.Create(studio) if err != nil { @@ -740,7 +790,7 @@ func createStudio(sqb models.StudioReaderWriter, name string, parentID *int64) ( return created, nil } -//createStudios creates n studios with plain Name and o studios with camel cased NaMe included +// createStudios creates n studios with plain Name and o studios with camel cased NaMe included func createStudios(sqb models.StudioReaderWriter, n int, o int) error { const namePlain = "Name" const nameNoCase = "NaMe" @@ -756,7 +806,12 @@ func createStudios(sqb models.StudioReaderWriter, n int, o int) error { // studios [ i ] and [ n + o - i - 1 ] should have similar names with only the Name!=NaMe part different name = getStudioStringValue(index, name) - created, err := createStudio(sqb, name, nil) + studio := models.Studio{ + Name: sql.NullString{String: name, Valid: true}, + Checksum: utils.MD5FromString(name), + URL: getStudioNullStringValue(index, urlField), + } + created, err := createStudioFromModel(sqb, studio) if err != nil { return err diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index fa08017a1..c3a984c03 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -129,11 +129,10 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF findFilter = &models.FindFilterType{} } - var whereClauses []string - var havingClauses []string - var args []interface{} - body := selectDistinctIDs("studios") - body += ` + query := qb.newQuery() + + query.body = selectDistinctIDs("studios") + query.body += ` left join scenes on studios.id = scenes.studio_id left join studio_stash_ids on studio_stash_ids.studio_id = studios.id ` @@ -142,44 +141,47 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF searchColumns := []string{"studios.name"} clause, thisArgs := getSearchBinding(searchColumns, *q, false) - whereClauses = append(whereClauses, clause) - args = append(args, thisArgs...) + query.addWhere(clause) + query.addArg(thisArgs...) } if parentsFilter := studioFilter.Parents; parentsFilter != nil && len(parentsFilter.Value) > 0 { - body += ` + query.body += ` left join studios as parent_studio on parent_studio.id = studios.parent_id ` for _, studioID := range parentsFilter.Value { - args = append(args, studioID) + query.addArg(studioID) } whereClause, havingClause := getMultiCriterionClause("studios", "parent_studio", "", "", "parent_id", parentsFilter) - whereClauses = appendClause(whereClauses, whereClause) - havingClauses = appendClause(havingClauses, havingClause) + + query.addWhere(whereClause) + query.addHaving(havingClause) } if stashIDFilter := studioFilter.StashID; stashIDFilter != nil { - whereClauses = append(whereClauses, "studio_stash_ids.stash_id = ?") - args = append(args, stashIDFilter) + query.addWhere("studio_stash_ids.stash_id = ?") + query.addArg(stashIDFilter) } + query.handleStringCriterionInput(studioFilter.URL, "studios.url") + if isMissingFilter := studioFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { switch *isMissingFilter { case "image": - body += `left join studios_image on studios_image.studio_id = studios.id + query.body += `left join studios_image on studios_image.studio_id = studios.id ` - whereClauses = appendClause(whereClauses, "studios_image.studio_id IS NULL") + query.addWhere("studios_image.studio_id IS NULL") case "stash_id": - whereClauses = appendClause(whereClauses, "studio_stash_ids.studio_id IS NULL") + query.addWhere("studio_stash_ids.studio_id IS NULL") default: - whereClauses = appendClause(whereClauses, "studios."+*isMissingFilter+" IS NULL") + query.addWhere("studios." + *isMissingFilter + " IS NULL") } } - sortAndPagination := qb.getStudioSort(findFilter) + getPagination(findFilter) - idsResult, countResult, err := qb.executeFindQuery(body, args, sortAndPagination, whereClauses, havingClauses) + query.sortAndPagination = qb.getStudioSort(findFilter) + getPagination(findFilter) + idsResult, countResult, err := query.executeFind() if err != nil { return nil, 0, err } diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index 0dd3e4585..9325c720a 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -283,6 +283,71 @@ func TestStudioStashIDs(t *testing.T) { } } +func TestStudioQueryURL(t *testing.T) { + const sceneIdx = 1 + studioURL := getStudioStringValue(sceneIdx, urlField) + + urlCriterion := models.StringCriterionInput{ + Value: studioURL, + Modifier: models.CriterionModifierEquals, + } + + filter := models.StudioFilterType{ + URL: &urlCriterion, + } + + verifyFn := func(g *models.Studio) { + t.Helper() + verifyNullString(t, g.URL, urlCriterion) + } + + verifyStudioQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotEquals + verifyStudioQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierMatchesRegex + urlCriterion.Value = "studio_.*1_URL" + verifyStudioQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex + verifyStudioQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierIsNull + urlCriterion.Value = "" + verifyStudioQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotNull + verifyStudioQuery(t, filter, verifyFn) +} + +func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn func(s *models.Studio)) { + withTxn(func(r models.Repository) error { + t.Helper() + sqb := r.Studio() + + galleries := queryStudio(t, sqb, &filter, nil) + + // assume it should find at least one + assert.Greater(t, len(galleries), 0) + + for _, studio := range galleries { + verifyFn(studio) + } + + return nil + }) +} + +func queryStudio(t *testing.T, sqb models.StudioReader, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) []*models.Studio { + studios, _, err := sqb.Query(studioFilter, findFilter) + if err != nil { + t.Errorf("Error querying studio: %s", err.Error()) + } + + return studios +} + // TODO Create // TODO Update // TODO Destroy diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index e3452b69a..2ad53bfff 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -3,7 +3,8 @@ * Added scene queue. ### 🎨 Improvements -* Add HTTP endpoint for health checking at /healthz. +* Add URL filter criteria for scenes, galleries, movies, performers and studios. +* Add HTTP endpoint for health checking at `/healthz`. * Support `today` and `yesterday` for `parseDate` in scrapers. * Add random sorting option for galleries, studios, movies and tags. * Disable sounds on scene/marker wall previews by default. 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 213ca8263..f18a22366 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -47,7 +47,8 @@ export type CriterionType = | "marker_count" | "image_count" | "gallery_count" - | "performer_count"; + | "performer_count" + | "url"; type Option = string | number | IOptionType; export type CriterionValue = string | number | ILabeledId[]; @@ -135,6 +136,8 @@ export abstract class Criterion { return "Gallery Count"; case "performer_count": return "Performer Count"; + case "url": + return "URL"; } } 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 171b72e52..011574546 100644 --- a/ui/v2.5/src/models/list-filter/criteria/utils.ts +++ b/ui/v2.5/src/models/list-filter/criteria/utils.ts @@ -112,6 +112,7 @@ export function makeCriteria(type: CriterionType = "none") { case "tattoos": case "piercings": case "aliases": + case "url": return new StringCriterion(type, type); } } diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 68306518d..5ede1dee1 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -151,6 +151,7 @@ export class ListFilterModel { new PerformersCriterionOption(), new StudiosCriterionOption(), new MoviesCriterionOption(), + ListFilterModel.createCriterionOption("url"), ]; break; case FilterMode.Images: @@ -210,6 +211,7 @@ export class ListFilterModel { new GenderCriterionOption(), new PerformerIsMissingCriterionOption(), new TagsCriterionOption(), + ListFilterModel.createCriterionOption("url"), ...numberCriteria .concat(stringCriteria) .map((c) => ListFilterModel.createCriterionOption(c)), @@ -225,6 +227,7 @@ export class ListFilterModel { new NoneCriterionOption(), new ParentStudiosCriterionOption(), new StudioIsMissingCriterionOption(), + ListFilterModel.createCriterionOption("url"), ]; break; case FilterMode.Movies: @@ -235,6 +238,7 @@ export class ListFilterModel { new NoneCriterionOption(), new StudiosCriterionOption(), new MovieIsMissingCriterionOption(), + ListFilterModel.createCriterionOption("url"), ]; break; case FilterMode.Galleries: @@ -257,6 +261,7 @@ export class ListFilterModel { new PerformerTagsCriterionOption(), new PerformersCriterionOption(), new StudiosCriterionOption(), + ListFilterModel.createCriterionOption("url"), ]; this.displayModeOptions = [ DisplayMode.Grid, @@ -582,6 +587,14 @@ export class ListFilterModel { }; break; } + case "url": { + const urlCrit = criterion as StringCriterion; + result.url = { + value: urlCrit.value, + modifier: urlCrit.modifier, + }; + break; + } // no default } }); @@ -690,6 +703,14 @@ export class ListFilterModel { }; break; } + case "url": { + const urlCrit = criterion as StringCriterion; + result.url = { + value: urlCrit.value, + modifier: urlCrit.modifier, + }; + break; + } // no default } }); @@ -868,6 +889,14 @@ export class ListFilterModel { }; break; } + case "url": { + const urlCrit = criterion as StringCriterion; + result.url = { + value: urlCrit.value, + modifier: urlCrit.modifier, + }; + break; + } case "movieIsMissing": result.is_missing = (criterion as IsMissingCriterion).value; // no default @@ -888,6 +917,14 @@ export class ListFilterModel { }; break; } + case "url": { + const urlCrit = criterion as StringCriterion; + result.url = { + value: urlCrit.value, + modifier: urlCrit.modifier, + }; + break; + } case "studioIsMissing": result.is_missing = (criterion as IsMissingCriterion).value; // no default @@ -1001,6 +1038,14 @@ export class ListFilterModel { }; break; } + case "url": { + const urlCrit = criterion as StringCriterion; + result.url = { + value: urlCrit.value, + modifier: urlCrit.modifier, + }; + break; + } // no default } });