From e843c890fb37f96898ba74b2b1600dfd7860760f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 11 Jun 2024 11:34:38 +1000 Subject: [PATCH] Add related object filter criteria to various filter types in graphql schema (#4861) * Move filter criterion handlers into separate file * Add related filters for image filter * Add related filters for scene filter * Add related filters to gallery filter * Add related filters to movie filter * Add related filters to performer filter * Add related filters to studio filter * Add related filters to tag filter * Add scene filter to scene marker filter --- graphql/schema/types/filters.graphql | 65 ++ pkg/match/cache.go | 10 +- pkg/models/filter.go | 21 + pkg/models/gallery.go | 14 +- pkg/models/image.go | 12 +- pkg/models/movie.go | 5 + pkg/models/performer.go | 12 +- pkg/models/scene.go | 16 +- pkg/models/scene_marker.go | 2 + pkg/models/studio.go | 10 +- pkg/models/tag.go | 10 +- pkg/sqlite/blob.go | 10 +- pkg/sqlite/criterion_handlers.go | 984 +++++++++++++++++++++++++++ pkg/sqlite/database.go | 35 +- pkg/sqlite/file.go | 1 - pkg/sqlite/filter.go | 941 ++----------------------- pkg/sqlite/gallery.go | 550 +++------------ pkg/sqlite/gallery_filter.go | 432 ++++++++++++ pkg/sqlite/gallery_test.go | 32 +- pkg/sqlite/image.go | 424 +++--------- pkg/sqlite/image_filter.go | 290 ++++++++ pkg/sqlite/image_test.go | 32 +- pkg/sqlite/movies.go | 121 +--- pkg/sqlite/movies_filter.go | 150 ++++ pkg/sqlite/performer.go | 599 +++------------- pkg/sqlite/performer_filter.go | 516 ++++++++++++++ pkg/sqlite/performer_test.go | 44 +- pkg/sqlite/repository.go | 26 +- pkg/sqlite/scene.go | 682 +++---------------- pkg/sqlite/scene_filter.go | 533 +++++++++++++++ pkg/sqlite/scene_marker.go | 210 +----- pkg/sqlite/scene_marker_filter.go | 189 +++++ pkg/sqlite/scene_test.go | 32 +- pkg/sqlite/sql.go | 5 + pkg/sqlite/studio.go | 229 ++----- pkg/sqlite/studio_filter.go | 200 ++++++ pkg/sqlite/studio_test.go | 32 +- pkg/sqlite/table.go | 11 +- pkg/sqlite/tag.go | 465 ++----------- pkg/sqlite/tag_filter.go | 395 +++++++++++ pkg/sqlite/tx.go | 20 +- 41 files changed, 4562 insertions(+), 3805 deletions(-) create mode 100644 pkg/sqlite/gallery_filter.go create mode 100644 pkg/sqlite/image_filter.go create mode 100644 pkg/sqlite/movies_filter.go create mode 100644 pkg/sqlite/performer_filter.go create mode 100644 pkg/sqlite/scene_filter.go create mode 100644 pkg/sqlite/scene_marker_filter.go create mode 100644 pkg/sqlite/studio_filter.go create mode 100644 pkg/sqlite/tag_filter.go diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 5d5209006..92127416f 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -170,6 +170,14 @@ input PerformerFilterType { birthdate: DateCriterionInput "Filter by death date" death_date: DateCriterionInput + "Filter by related scenes that meet this criteria" + scenes_filter: SceneFilterType + "Filter by related images that meet this criteria" + images_filter: ImageFilterType + "Filter by related galleries that meet this criteria" + galleries_filter: GalleryFilterType + "Filter by related tags that meet this criteria" + tags_filter: TagFilterType "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" @@ -193,6 +201,8 @@ input SceneMarkerFilterType { scene_created_at: TimestampCriterionInput "Filter by lscene ast update time" scene_updated_at: TimestampCriterionInput + "Filter by related scenes that meet this criteria" + scene_filter: SceneFilterType } input SceneFilterType { @@ -288,9 +298,26 @@ input SceneFilterType { created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput + + "Filter by related galleries that meet this criteria" + galleries_filter: GalleryFilterType + "Filter by related performers that meet this criteria" + performers_filter: PerformerFilterType + "Filter by related studios that meet this criteria" + studios_filter: StudioFilterType + "Filter by related tags that meet this criteria" + tags_filter: TagFilterType + "Filter by related movies that meet this criteria" + movies_filter: MovieFilterType + "Filter by related markers that meet this criteria" + markers_filter: SceneMarkerFilterType } input MovieFilterType { + AND: MovieFilterType + OR: MovieFilterType + NOT: MovieFilterType + name: StringCriterionInput director: StringCriterionInput synopsis: StringCriterionInput @@ -313,6 +340,11 @@ input MovieFilterType { created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput + + "Filter by related scenes that meet this criteria" + scenes_filter: SceneFilterType + "Filter by related studios that meet this criteria" + studios_filter: StudioFilterType } input StudioFilterType { @@ -346,6 +378,12 @@ input StudioFilterType { child_count: IntCriterionInput "Filter by autotag ignore value" ignore_auto_tag: Boolean + "Filter by related scenes that meet this criteria" + scenes_filter: SceneFilterType + "Filter by related images that meet this criteria" + images_filter: ImageFilterType + "Filter by related galleries that meet this criteria" + galleries_filter: GalleryFilterType "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" @@ -411,6 +449,17 @@ input GalleryFilterType { code: StringCriterionInput "Filter by photographer" photographer: StringCriterionInput + + "Filter by related scenes that meet this criteria" + scenes_filter: SceneFilterType + "Filter by related images that meet this criteria" + images_filter: ImageFilterType + "Filter by related performers that meet this criteria" + performers_filter: PerformerFilterType + "Filter by related studios that meet this criteria" + studios_filter: StudioFilterType + "Filter by related tags that meet this criteria" + tags_filter: TagFilterType } input TagFilterType { @@ -463,6 +512,13 @@ input TagFilterType { "Filter by autotag ignore value" ignore_auto_tag: Boolean + "Filter by related scenes that meet this criteria" + scenes_filter: SceneFilterType + "Filter by related images that meet this criteria" + images_filter: ImageFilterType + "Filter by related galleries that meet this criteria" + galleries_filter: GalleryFilterType + "Filter by creation time" created_at: TimestampCriterionInput @@ -528,6 +584,15 @@ input ImageFilterType { code: StringCriterionInput "Filter by photographer" photographer: StringCriterionInput + + "Filter by related galleries that meet this criteria" + galleries_filter: GalleryFilterType + "Filter by related performers that meet this criteria" + performers_filter: PerformerFilterType + "Filter by related studios that meet this criteria" + studios_filter: StudioFilterType + "Filter by related tags that meet this criteria" + tags_filter: TagFilterType } enum CriterionModifier { diff --git a/pkg/match/cache.go b/pkg/match/cache.go index 6946f65db..002d67116 100644 --- a/pkg/match/cache.go +++ b/pkg/match/cache.go @@ -98,10 +98,12 @@ func getSingleLetterTags(ctx context.Context, c *Cache, reader models.TagAutoTag Value: singleFirstCharacterRegex, Modifier: models.CriterionModifierMatchesRegex, }, - Or: &models.TagFilterType{ - Aliases: &models.StringCriterionInput{ - Value: singleFirstCharacterRegex, - Modifier: models.CriterionModifierMatchesRegex, + OperatorFilter: models.OperatorFilter[models.TagFilterType]{ + Or: &models.TagFilterType{ + Aliases: &models.StringCriterionInput{ + Value: singleFirstCharacterRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, }, }, }, &models.FindFilterType{ diff --git a/pkg/models/filter.go b/pkg/models/filter.go index 1513b0bbe..577aef42b 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -6,6 +6,27 @@ import ( "strconv" ) +type OperatorFilter[T any] struct { + And *T `json:"AND"` + Or *T `json:"OR"` + Not *T `json:"NOT"` +} + +// SubFilter returns the subfilter of the operator filter. +// Only one of And, Or, or Not should be set, so it returns the first of these that are not nil. +func (f *OperatorFilter[T]) SubFilter() *T { + if f.And != nil { + return f.And + } + if f.Or != nil { + return f.Or + } + if f.Not != nil { + return f.Not + } + return nil +} + type CriterionModifier string const ( diff --git a/pkg/models/gallery.go b/pkg/models/gallery.go index 0145ff5e6..73fa287d2 100644 --- a/pkg/models/gallery.go +++ b/pkg/models/gallery.go @@ -1,9 +1,7 @@ package models type GalleryFilterType struct { - And *GalleryFilterType `json:"AND"` - Or *GalleryFilterType `json:"OR"` - Not *GalleryFilterType `json:"NOT"` + OperatorFilter[GalleryFilterType] ID *IntCriterionInput `json:"id"` Title *StringCriterionInput `json:"title"` Code *StringCriterionInput `json:"code"` @@ -51,6 +49,16 @@ type GalleryFilterType struct { URL *StringCriterionInput `json:"url"` // Filter by date Date *DateCriterionInput `json:"date"` + // Filter by related scenes that meet this criteria + ScenesFilter *SceneFilterType `json:"scenes_filter"` + // Filter by related images that meet this criteria + ImagesFilter *ImageFilterType `json:"images_filter"` + // Filter by related performers that meet this criteria + PerformersFilter *PerformerFilterType `json:"performers_filter"` + // Filter by related studios that meet this criteria + StudiosFilter *StudioFilterType `json:"studios_filter"` + // Filter by related tags that meet this criteria + TagsFilter *TagFilterType `json:"tags_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/models/image.go b/pkg/models/image.go index 8dca73991..6026070fa 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -3,9 +3,7 @@ package models import "context" type ImageFilterType struct { - And *ImageFilterType `json:"AND"` - Or *ImageFilterType `json:"OR"` - Not *ImageFilterType `json:"NOT"` + OperatorFilter[ImageFilterType] ID *IntCriterionInput `json:"id"` Title *StringCriterionInput `json:"title"` Code *StringCriterionInput `json:"code"` @@ -51,6 +49,14 @@ type ImageFilterType struct { PerformerAge *IntCriterionInput `json:"performer_age"` // Filter to only include images with these galleries Galleries *MultiCriterionInput `json:"galleries"` + // Filter by related galleries that meet this criteria + GalleriesFilter *GalleryFilterType `json:"galleries_filter"` + // Filter by related performers that meet this criteria + PerformersFilter *PerformerFilterType `json:"performers_filter"` + // Filter by related studios that meet this criteria + StudiosFilter *StudioFilterType `json:"studios_filter"` + // Filter by related tags that meet this criteria + TagsFilter *TagFilterType `json:"tags_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/models/movie.go b/pkg/models/movie.go index c4afab0e5..95c6efdd1 100644 --- a/pkg/models/movie.go +++ b/pkg/models/movie.go @@ -1,6 +1,7 @@ package models type MovieFilterType struct { + OperatorFilter[MovieFilterType] Name *StringCriterionInput `json:"name"` Director *StringCriterionInput `json:"director"` Synopsis *StringCriterionInput `json:"synopsis"` @@ -18,6 +19,10 @@ type MovieFilterType struct { Performers *MultiCriterionInput `json:"performers"` // Filter by date Date *DateCriterionInput `json:"date"` + // Filter by related scenes that meet this criteria + ScenesFilter *SceneFilterType `json:"scenes_filter"` + // Filter by related studios that meet this criteria + StudiosFilter *StudioFilterType `json:"studios_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/models/performer.go b/pkg/models/performer.go index 9f5b1b51f..75b0f85af 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -108,9 +108,7 @@ type CircumcisionCriterionInput struct { } type PerformerFilterType struct { - And *PerformerFilterType `json:"AND"` - Or *PerformerFilterType `json:"OR"` - Not *PerformerFilterType `json:"NOT"` + OperatorFilter[PerformerFilterType] Name *StringCriterionInput `json:"name"` Disambiguation *StringCriterionInput `json:"disambiguation"` Details *StringCriterionInput `json:"details"` @@ -188,6 +186,14 @@ type PerformerFilterType struct { Birthdate *DateCriterionInput `json:"birth_date"` // Filter by death date DeathDate *DateCriterionInput `json:"death_date"` + // Filter by related scenes that meet this criteria + ScenesFilter *SceneFilterType `json:"scenes_filter"` + // Filter by related images that meet this criteria + ImagesFilter *ImageFilterType `json:"images_filter"` + // Filter by related galleries that meet this criteria + GalleriesFilter *GalleryFilterType `json:"galleries_filter"` + // Filter by related tags that meet this criteria + TagsFilter *TagFilterType `json:"tags_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/models/scene.go b/pkg/models/scene.go index c7a87151c..8a2ffde8d 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -9,9 +9,7 @@ type PHashDuplicationCriterionInput struct { } type SceneFilterType struct { - And *SceneFilterType `json:"AND"` - Or *SceneFilterType `json:"OR"` - Not *SceneFilterType `json:"NOT"` + OperatorFilter[SceneFilterType] ID *IntCriterionInput `json:"id"` Title *StringCriterionInput `json:"title"` Code *StringCriterionInput `json:"code"` @@ -97,6 +95,18 @@ type SceneFilterType struct { LastPlayedAt *TimestampCriterionInput `json:"last_played_at"` // Filter by date Date *DateCriterionInput `json:"date"` + // Filter by related galleries that meet this criteria + GalleriesFilter *GalleryFilterType `json:"galleries_filter"` + // Filter by related performers that meet this criteria + PerformersFilter *PerformerFilterType `json:"performers_filter"` + // Filter by related studios that meet this criteria + StudiosFilter *StudioFilterType `json:"studios_filter"` + // Filter by related tags that meet this criteria + TagsFilter *TagFilterType `json:"tags_filter"` + // Filter by related movies that meet this criteria + MoviesFilter *MovieFilterType `json:"movies_filter"` + // Filter by related markers that meet this criteria + MarkersFilter *SceneMarkerFilterType `json:"markers_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/models/scene_marker.go b/pkg/models/scene_marker.go index 4a10c0e21..59186ca59 100644 --- a/pkg/models/scene_marker.go +++ b/pkg/models/scene_marker.go @@ -19,6 +19,8 @@ type SceneMarkerFilterType struct { SceneCreatedAt *TimestampCriterionInput `json:"scene_created_at"` // Filter by scenes updated at SceneUpdatedAt *TimestampCriterionInput `json:"scene_updated_at"` + // Filter by related scenes that meet this criteria + SceneFilter *SceneFilterType `json:"scene_filter"` } type MarkerStringsResultType struct { diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 9cc6b907e..0f8b5d153 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -1,9 +1,7 @@ package models type StudioFilterType struct { - And *StudioFilterType `json:"AND"` - Or *StudioFilterType `json:"OR"` - Not *StudioFilterType `json:"NOT"` + OperatorFilter[StudioFilterType] Name *StringCriterionInput `json:"name"` Details *StringCriterionInput `json:"details"` // Filter to only include studios with this parent studio @@ -32,6 +30,12 @@ type StudioFilterType struct { ChildCount *IntCriterionInput `json:"child_count"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` + // Filter by related scenes that meet this criteria + ScenesFilter *SceneFilterType `json:"scenes_filter"` + // Filter by related images that meet this criteria + ImagesFilter *ImageFilterType `json:"images_filter"` + // Filter by related galleries that meet this criteria + GalleriesFilter *GalleryFilterType `json:"galleries_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 710d1953e..d51ec9787 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -1,9 +1,7 @@ package models type TagFilterType struct { - And *TagFilterType `json:"AND"` - Or *TagFilterType `json:"OR"` - Not *TagFilterType `json:"NOT"` + OperatorFilter[TagFilterType] // Filter by tag name Name *StringCriterionInput `json:"name"` // Filter by tag aliases @@ -34,6 +32,12 @@ type TagFilterType struct { ChildCount *IntCriterionInput `json:"child_count"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` + // Filter by related scenes that meet this criteria + ScenesFilter *SceneFilterType `json:"scenes_filter"` + // Filter by related images that meet this criteria + ImagesFilter *ImageFilterType `json:"images_filter"` + // Filter by related galleries that meet this criteria + GalleriesFilter *GalleryFilterType `json:"galleries_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/sqlite/blob.go b/pkg/sqlite/blob.go index 31b406fc5..241b63d23 100644 --- a/pkg/sqlite/blob.go +++ b/pkg/sqlite/blob.go @@ -346,8 +346,8 @@ func (qb *BlobStore) delete(ctx context.Context, checksum string) error { } type blobJoinQueryBuilder struct { - repository - blobStore *BlobStore + repository repository + blobStore *BlobStore joinTable string } @@ -381,7 +381,7 @@ func (qb *blobJoinQueryBuilder) UpdateImage(ctx context.Context, id int, blobCol } sqlQuery := fmt.Sprintf("UPDATE %s SET %s = ? WHERE id = ?", qb.joinTable, blobCol) - if _, err := qb.tx.Exec(ctx, sqlQuery, checksum, id); err != nil { + if _, err := dbWrapper.Exec(ctx, sqlQuery, checksum, id); err != nil { return err } @@ -428,7 +428,7 @@ func (qb *blobJoinQueryBuilder) DestroyImage(ctx context.Context, id int, blobCo } updateQuery := fmt.Sprintf("UPDATE %s SET %s = NULL WHERE id = ?", qb.joinTable, blobCol) - if _, err = qb.tx.Exec(ctx, updateQuery, id); err != nil { + if _, err = dbWrapper.Exec(ctx, updateQuery, id); err != nil { return err } @@ -441,7 +441,7 @@ func (qb *blobJoinQueryBuilder) HasImage(ctx context.Context, id int, blobCol st "joinCol": blobCol, }) - c, err := qb.runCountQuery(ctx, stmt, []interface{}{id}) + c, err := qb.repository.runCountQuery(ctx, stmt, []interface{}{id}) if err != nil { return false, err } diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go index 5718947cb..243f1f54e 100644 --- a/pkg/sqlite/criterion_handlers.go +++ b/pkg/sqlite/criterion_handlers.go @@ -2,13 +2,308 @@ package sqlite import ( "context" + "database/sql" "fmt" + "path/filepath" + "regexp" + "strconv" + "strings" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" ) +type criterionHandler interface { + handle(ctx context.Context, f *filterBuilder) +} + +type criterionHandlerFunc func(ctx context.Context, f *filterBuilder) + +func (h criterionHandlerFunc) handle(ctx context.Context, f *filterBuilder) { + h(ctx, f) +} + +type compoundHandler []criterionHandler + +func (h compoundHandler) handle(ctx context.Context, f *filterBuilder) { + for _, h := range h { + h.handle(ctx, f) + } +} + // shared criterion handlers go here +func stringCriterionHandler(c *models.StringCriterionInput, column string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + if modifier := c.Modifier; c.Modifier.IsValid() { + switch modifier { + case models.CriterionModifierIncludes: + f.whereClauses = append(f.whereClauses, getStringSearchClause([]string{column}, c.Value, false)) + case models.CriterionModifierExcludes: + f.whereClauses = append(f.whereClauses, getStringSearchClause([]string{column}, c.Value, true)) + case models.CriterionModifierEquals: + f.addWhere(column+" LIKE ?", c.Value) + case models.CriterionModifierNotEquals: + f.addWhere(column+" NOT LIKE ?", c.Value) + case models.CriterionModifierMatchesRegex: + if _, err := regexp.Compile(c.Value); err != nil { + f.setError(err) + return + } + f.addWhere(fmt.Sprintf("(%s IS NOT NULL AND %[1]s regexp ?)", column), c.Value) + case models.CriterionModifierNotMatchesRegex: + if _, err := regexp.Compile(c.Value); err != nil { + f.setError(err) + 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: + panic("unsupported string filter modifier") + } + } + } + } +} + +func enumCriterionHandler(modifier models.CriterionModifier, values []string, column string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if modifier.IsValid() { + switch modifier { + case models.CriterionModifierIncludes, models.CriterionModifierEquals: + if len(values) > 0 { + f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, false)) + } + case models.CriterionModifierExcludes, models.CriterionModifierNotEquals: + if len(values) > 0 { + f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, true)) + } + case models.CriterionModifierIsNull: + f.addWhere("(" + column + " IS NULL OR TRIM(" + column + ") = '')") + case models.CriterionModifierNotNull: + f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')") + default: + panic("unsupported string filter modifier") + } + } + } +} + +func pathCriterionHandler(c *models.StringCriterionInput, pathColumn string, basenameColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + if addJoinFn != nil { + addJoinFn(f) + } + addWildcards := true + not := false + + if modifier := c.Modifier; c.Modifier.IsValid() { + switch modifier { + case models.CriterionModifierIncludes: + f.whereClauses = append(f.whereClauses, getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not)) + case models.CriterionModifierExcludes: + not = true + f.whereClauses = append(f.whereClauses, getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not)) + case models.CriterionModifierEquals: + addWildcards = false + f.whereClauses = append(f.whereClauses, getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not)) + case models.CriterionModifierNotEquals: + addWildcards = false + not = true + f.whereClauses = append(f.whereClauses, getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not)) + case models.CriterionModifierMatchesRegex: + if _, err := regexp.Compile(c.Value); err != nil { + f.setError(err) + return + } + filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) + f.addWhere(fmt.Sprintf("%s IS NOT NULL AND %s IS NOT NULL AND %s regexp ?", pathColumn, basenameColumn, filepathColumn), c.Value) + case models.CriterionModifierNotMatchesRegex: + if _, err := regexp.Compile(c.Value); err != nil { + f.setError(err) + return + } + filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) + f.addWhere(fmt.Sprintf("%s IS NULL OR %s IS NULL OR %s NOT regexp ?", pathColumn, basenameColumn, filepathColumn), c.Value) + case models.CriterionModifierIsNull: + f.addWhere(fmt.Sprintf("%s IS NULL OR TRIM(%[1]s) = '' OR %s IS NULL OR TRIM(%[2]s) = ''", pathColumn, basenameColumn)) + case models.CriterionModifierNotNull: + f.addWhere(fmt.Sprintf("%s IS NOT NULL AND TRIM(%[1]s) != '' AND %s IS NOT NULL AND TRIM(%[2]s) != ''", pathColumn, basenameColumn)) + default: + panic("unsupported string filter modifier") + } + } + } + } +} + +func getPathSearchClause(pathColumn, basenameColumn, p string, addWildcards, not bool) sqlClause { + if addWildcards { + p = "%" + p + "%" + } + + filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) + ret := makeClause(fmt.Sprintf("%s LIKE ?", filepathColumn), p) + + if not { + ret = ret.not() + } + + return ret +} + +// getPathSearchClauseMany splits the query string p on whitespace +// Used for backwards compatibility for the includes/excludes modifiers +func getPathSearchClauseMany(pathColumn, basenameColumn, p string, addWildcards, not bool) sqlClause { + q := strings.TrimSpace(p) + trimmedQuery := strings.Trim(q, "\"") + + if trimmedQuery == q { + q = regexp.MustCompile(`\s+`).ReplaceAllString(q, " ") + queryWords := strings.Split(q, " ") + + var ret []sqlClause + // Search for any word + for _, word := range queryWords { + ret = append(ret, getPathSearchClause(pathColumn, basenameColumn, word, addWildcards, not)) + } + + if !not { + return orClauses(ret...) + } + + return andClauses(ret...) + } + + return getPathSearchClause(pathColumn, basenameColumn, trimmedQuery, addWildcards, not) +} + +func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + if addJoinFn != nil { + addJoinFn(f) + } + clause, args := getIntCriterionWhereClause(column, *c) + f.addWhere(clause, args...) + } + } +} + +func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + if addJoinFn != nil { + addJoinFn(f) + } + clause, args := getFloatCriterionWhereClause(column, *c) + f.addWhere(clause, args...) + } + } +} + +func floatIntCriterionHandler(durationFilter *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if durationFilter != nil { + if addJoinFn != nil { + addJoinFn(f) + } + clause, args := getIntCriterionWhereClause("cast("+column+" as int)", *durationFilter) + f.addWhere(clause, args...) + } + } +} + +func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + if addJoinFn != nil { + addJoinFn(f) + } + var v string + if *c { + v = "1" + } else { + v = "0" + } + + f.addWhere(column + " = " + v) + } + } +} + +type dateCriterionHandler struct { + c *models.DateCriterionInput + column string + joinFn func(f *filterBuilder) +} + +func (h *dateCriterionHandler) handle(ctx context.Context, f *filterBuilder) { + if h.c != nil { + if h.joinFn != nil { + h.joinFn(f) + } + clause, args := getDateCriterionWhereClause(h.column, *h.c) + f.addWhere(clause, args...) + } +} + +type timestampCriterionHandler struct { + c *models.TimestampCriterionInput + column string + joinFn func(f *filterBuilder) +} + +func (h *timestampCriterionHandler) handle(ctx context.Context, f *filterBuilder) { + if h.c != nil { + if h.joinFn != nil { + h.joinFn(f) + } + clause, args := getTimestampCriterionWhereClause(h.column, *h.c) + f.addWhere(clause, args...) + } +} + +func yearFilterCriterionHandler(year *models.IntCriterionInput, col string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if year != nil && year.Modifier.IsValid() { + clause, args := getIntCriterionWhereClause("cast(strftime('%Y', "+col+") as int)", *year) + f.addWhere(clause, args...) + } + } +} + +func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if resolution != nil && resolution.Value.IsValid() { + if addJoinFn != nil { + addJoinFn(f) + } + + min := resolution.Value.GetMinResolution() + max := resolution.Value.GetMaxResolution() + + widthHeight := fmt.Sprintf("MIN(%s, %s)", widthColumn, heightColumn) + + switch resolution.Modifier { + case models.CriterionModifierEquals: + f.addWhere(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, min, max)) + case models.CriterionModifierNotEquals: + f.addWhere(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, min, max)) + case models.CriterionModifierLessThan: + f.addWhere(fmt.Sprintf("%s < %d", widthHeight, min)) + case models.CriterionModifierGreaterThan: + f.addWhere(fmt.Sprintf("%s > %d", widthHeight, max)) + } + } + } +} + func orientationCriterionHandler(orientation *models.OrientationCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if orientation != nil { @@ -41,3 +336,692 @@ func orientationCriterionHandler(orientation *models.OrientationCriterionInput, } } } + +// handle for MultiCriterion where there is a join table between the new +// objects +type joinedMultiCriterionHandlerBuilder struct { + // table containing the primary objects + primaryTable string + // table joining primary and foreign objects + joinTable string + // alias for join table, if required + joinAs string + // foreign key of the primary object on the join table + primaryFK string + // foreign key of the foreign object on the join table + foreignFK string + + addJoinTable func(f *filterBuilder) +} + +func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + // make local copy so we can modify it + criterion := *c + + joinAlias := m.joinAs + if joinAlias == "" { + joinAlias = m.joinTable + } + + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + m.addJoinTable(f) + + f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ + "table": joinAlias, + "column": m.foreignFK, + "not": notClause, + })) + return + } + + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { + return + } + + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludesAll + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil + } + + if len(criterion.Value) > 0 { + whereClause := "" + havingClause := "" + + var args []interface{} + for _, tagID := range criterion.Value { + args = append(args, tagID) + } + + switch criterion.Modifier { + case models.CriterionModifierIncludes: + // includes any of the provided ids + m.addJoinTable(f) + whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) + case models.CriterionModifierEquals: + // includes only the provided ids + m.addJoinTable(f) + whereClause = utils.StrFormat("{joinAlias}.{foreignFK} IN {inBinding} AND (SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{ + "joinAlias": joinAlias, + "foreignFK": m.foreignFK, + "inBinding": getInBinding(len(criterion.Value)), + "joinTable": m.joinTable, + "primaryFK": m.primaryFK, + "primaryTable": m.primaryTable, + }) + havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) + args = append(args, len(criterion.Value)) + case models.CriterionModifierNotEquals: + f.setError(fmt.Errorf("not equals modifier is not supported for multi criterion input")) + case models.CriterionModifierIncludesAll: + // includes all of the provided ids + m.addJoinTable(f) + whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) + havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) + } + + f.addWhere(whereClause, args...) + f.addHaving(havingClause) + } + + if len(criterion.Excludes) > 0 { + var args []interface{} + for _, tagID := range criterion.Excludes { + args = append(args, tagID) + } + + // excludes all of the provided ids + // need to use actual join table name for this + // .id NOT IN (select . from where . in ) + whereClause := fmt.Sprintf("%[1]s.id NOT IN (SELECT %[3]s.%[2]s from %[3]s where %[3]s.%[4]s in %[5]s)", m.primaryTable, m.primaryFK, m.joinTable, m.foreignFK, getInBinding(len(criterion.Excludes))) + + f.addWhere(whereClause, args...) + } + } + } +} + +type multiCriterionHandlerBuilder struct { + primaryTable string + foreignTable string + joinTable string + primaryFK string + foreignFK string + + // function that will be called to perform any necessary joins + addJoinsFunc func(f *filterBuilder) +} + +func (m *multiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + table := m.primaryTable + if m.joinTable != "" { + table = m.joinTable + f.addLeftJoin(table, "", fmt.Sprintf("%s.%s = %s.id", table, m.primaryFK, m.primaryTable)) + } + + f.addWhere(fmt.Sprintf("%s.%s IS %s NULL", table, m.foreignFK, notClause)) + return + } + + if len(criterion.Value) == 0 { + return + } + + var args []interface{} + for _, tagID := range criterion.Value { + args = append(args, tagID) + } + + if m.addJoinsFunc != nil { + m.addJoinsFunc(f) + } + + whereClause, havingClause := getMultiCriterionClause(m.primaryTable, m.foreignTable, m.joinTable, m.primaryFK, m.foreignFK, criterion) + f.addWhere(whereClause, args...) + f.addHaving(havingClause) + } + } +} + +type countCriterionHandlerBuilder struct { + primaryTable string + joinTable string + primaryFK string +} + +func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + clause, args := getCountCriterionClause(m.primaryTable, m.joinTable, m.primaryFK, *criterion) + + f.addWhere(clause, args...) + } + } +} + +// handler for StringCriterion for string list fields +type stringListCriterionHandlerBuilder struct { + // table joining primary and foreign objects + joinTable string + // string field on the join table + stringColumn string + + addJoinTable func(f *filterBuilder) +} + +func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + m.addJoinTable(f) + + stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(ctx, f) + } + } +} + +func studioCriterionHandler(primaryTable string, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if studios == nil { + return + } + + studiosCopy := *studios + switch studiosCopy.Modifier { + case models.CriterionModifierEquals: + studiosCopy.Modifier = models.CriterionModifierIncludesAll + case models.CriterionModifierNotEquals: + studiosCopy.Modifier = models.CriterionModifierExcludes + } + + hh := hierarchicalMultiCriterionHandlerBuilder{ + primaryTable: primaryTable, + foreignTable: studioTable, + foreignFK: studioIDColumn, + parentFK: "parent_id", + } + + hh.handler(&studiosCopy)(ctx, f) + } +} + +type hierarchicalMultiCriterionHandlerBuilder struct { + primaryTable string + foreignTable string + foreignFK string + + parentFK string + childFK string + relationsTable string +} + +func getHierarchicalValues(ctx context.Context, values []string, table, relationsTable, parentFK string, childFK string, depth *int) (string, error) { + var args []interface{} + + if parentFK == "" { + parentFK = "parent_id" + } + if childFK == "" { + childFK = "child_id" + } + + depthVal := 0 + if depth != nil { + depthVal = *depth + } + + if depthVal == 0 { + valid := true + var valuesClauses []string + for _, value := range values { + id, err := strconv.Atoi(value) + // In case of invalid value just run the query. + // Building VALUES() based on provided values just saves a query when depth is 0. + if err != nil { + valid = false + break + } + + valuesClauses = append(valuesClauses, fmt.Sprintf("(%d,%d)", id, id)) + } + + if valid { + return "VALUES" + strings.Join(valuesClauses, ","), nil + } + } + + for _, value := range values { + args = append(args, value) + } + inCount := len(args) + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + withClauseMap := utils.StrFormatMap{ + "table": table, + "relationsTable": relationsTable, + "inBinding": getInBinding(inCount), + "recursiveSelect": "", + "parentFK": parentFK, + "childFK": childFK, + "depthCondition": depthCondition, + "unionClause": "", + } + + if relationsTable != "" { + withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.{childFK}, depth + 1 FROM {relationsTable} AS c +INNER JOIN items as p ON c.{parentFK} = p.item_id +`, withClauseMap) + } else { + withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.id, depth + 1 FROM {table} as c +INNER JOIN items as p ON c.{parentFK} = p.item_id +`, withClauseMap) + } + + if depthVal != 0 { + withClauseMap["unionClause"] = utils.StrFormat(` +UNION {recursiveSelect} {depthCondition} +`, withClauseMap) + } + + withClause := utils.StrFormat(`items AS ( +SELECT id as root_id, id as item_id, 0 as depth FROM {table} +WHERE id in {inBinding} +{unionClause}) +`, withClauseMap) + + query := fmt.Sprintf("WITH RECURSIVE %s SELECT 'VALUES' || GROUP_CONCAT('(' || root_id || ', ' || item_id || ')') AS val FROM items", withClause) + + var valuesClause sql.NullString + err := dbWrapper.Get(ctx, &valuesClause, query, args...) + if err != nil { + return "", fmt.Errorf("failed to get hierarchical values: %w", err) + } + + // if no values are found, just return a values string with the values only + if !valuesClause.Valid { + for i, value := range values { + values[i] = fmt.Sprintf("(%s, %s)", value, value) + } + valuesClause.String = "VALUES" + strings.Join(values, ",") + } + + return valuesClause.String, nil +} + +func addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { + switch criterion.Modifier { + case models.CriterionModifierIncludes: + f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) + case models.CriterionModifierIncludesAll: + f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) + f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", table, idColumn, len(criterion.Value))) + case models.CriterionModifierExcludes: + f.addWhere(fmt.Sprintf("%s.%s IS NULL", table, idColumn)) + } +} + +func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + // make a copy so we don't modify the original + criterion := *c + + // don't support equals/not equals + if criterion.Modifier == models.CriterionModifierEquals || criterion.Modifier == models.CriterionModifierNotEquals { + f.setError(fmt.Errorf("modifier %s is not supported for hierarchical multi criterion", criterion.Modifier)) + return + } + + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ + "table": m.primaryTable, + "column": m.foreignFK, + "not": notClause, + })) + return + } + + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { + return + } + + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludesAll + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil + } + + if len(criterion.Value) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) + if err != nil { + f.setError(err) + return + } + + switch criterion.Modifier { + case models.CriterionModifierIncludes: + f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) + case models.CriterionModifierIncludesAll: + f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) + f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", m.primaryTable, m.foreignFK, len(criterion.Value))) + } + } + + if len(criterion.Excludes) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) + if err != nil { + f.setError(err) + return + } + + f.addWhere(fmt.Sprintf("%s.%s NOT IN (SELECT column2 FROM (%s)) OR %[1]s.%[2]s IS NULL", m.primaryTable, m.foreignFK, valuesClause)) + } + } + } +} + +type joinedHierarchicalMultiCriterionHandlerBuilder struct { + primaryTable string + primaryKey string + foreignTable string + foreignFK string + + parentFK string + childFK string + relationsTable string + + joinAs string + joinTable string + primaryFK string +} + +func (m *joinedHierarchicalMultiCriterionHandlerBuilder) addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { + primaryKey := m.primaryKey + if primaryKey == "" { + primaryKey = "id" + } + + switch criterion.Modifier { + case models.CriterionModifierEquals: + // includes only the provided ids + f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) + f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", table, idColumn, len(criterion.Value))) + f.addWhere(utils.StrFormat("(SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.{primaryKey}) = ?", utils.StrFormatMap{ + "joinTable": m.joinTable, + "primaryFK": m.primaryFK, + "primaryTable": m.primaryTable, + "primaryKey": primaryKey, + }), len(criterion.Value)) + case models.CriterionModifierNotEquals: + f.setError(fmt.Errorf("not equals modifier is not supported for hierarchical multi criterion input")) + default: + addHierarchicalConditionClauses(f, criterion, table, idColumn) + } +} + +func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + // make a copy so we don't modify the original + criterion := *c + joinAlias := m.joinAs + primaryKey := m.primaryKey + if primaryKey == "" { + primaryKey = "id" + } + + if criterion.Modifier == models.CriterionModifierEquals && criterion.Depth != nil && *criterion.Depth != 0 { + f.setError(fmt.Errorf("depth is not supported for equals modifier in hierarchical multi criterion input")) + return + } + + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addLeftJoin(m.joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.%s", joinAlias, m.primaryFK, m.primaryTable, primaryKey)) + + f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ + "table": joinAlias, + "column": m.foreignFK, + "not": notClause, + })) + return + } + + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludesAll + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil + } + + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { + return + } + + if len(criterion.Value) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) + if err != nil { + f.setError(err) + return + } + + joinTable := utils.StrFormat(`( + SELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j + INNER JOIN ({valuesClause}) AS d ON j.{foreignFK} = d.column2 + ) + `, utils.StrFormatMap{ + "joinTable": m.joinTable, + "foreignFK": m.foreignFK, + "valuesClause": valuesClause, + }) + + f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.%s", joinAlias, m.primaryFK, m.primaryTable, primaryKey)) + + m.addHierarchicalConditionClauses(f, criterion, joinAlias, "root_id") + } + + if len(criterion.Excludes) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) + if err != nil { + f.setError(err) + return + } + + joinTable := utils.StrFormat(`( + SELECT j2.*, e.column1 AS root_id, e.column2 AS item_id FROM {joinTable} AS j2 + INNER JOIN ({valuesClause}) AS e ON j2.{foreignFK} = e.column2 + ) + `, utils.StrFormatMap{ + "joinTable": m.joinTable, + "foreignFK": m.foreignFK, + "valuesClause": valuesClause, + }) + + joinAlias2 := joinAlias + "2" + + f.addLeftJoin(joinTable, joinAlias2, fmt.Sprintf("%s.%s = %s.%s", joinAlias2, m.primaryFK, m.primaryTable, primaryKey)) + + // modify for exclusion + criterionCopy := criterion + criterionCopy.Modifier = models.CriterionModifierExcludes + criterionCopy.Value = c.Excludes + + m.addHierarchicalConditionClauses(f, criterionCopy, joinAlias2, "root_id") + } + } + } +} + +type joinedPerformerTagsHandler struct { + criterion *models.HierarchicalMultiCriterionInput + + primaryTable string // eg scenes + joinTable string // eg performers_scenes + joinPrimaryKey string // eg scene_id +} + +func (h *joinedPerformerTagsHandler) handle(ctx context.Context, f *filterBuilder) { + tags := h.criterion + + if tags != nil { + criterion := tags.CombineExcludes() + + // validate the modifier + switch criterion.Modifier { + case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: + // valid + default: + f.setError(fmt.Errorf("invalid modifier %s for performer tags", criterion.Modifier)) + } + + strFormatMap := utils.StrFormatMap{ + "primaryTable": h.primaryTable, + "joinTable": h.joinTable, + "joinPrimaryKey": h.joinPrimaryKey, + "inBinding": getInBinding(len(criterion.Value)), + } + + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addLeftJoin(h.joinTable, "", utils.StrFormat("{primaryTable}.id = {joinTable}.{joinPrimaryKey}", strFormatMap)) + f.addLeftJoin("performers_tags", "", utils.StrFormat("{joinTable}.performer_id = performers_tags.performer_id", strFormatMap)) + + f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause)) + return + } + + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { + return + } + + if len(criterion.Value) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Value, tagTable, "tags_relations", "", "", criterion.Depth) + if err != nil { + f.setError(err) + return + } + + f.addWith(utils.StrFormat(`performer_tags AS ( +SELECT ps.{joinPrimaryKey} as primaryID, t.column1 AS root_tag_id FROM {joinTable} ps +INNER JOIN performers_tags pt ON pt.performer_id = ps.performer_id +INNER JOIN (`+valuesClause+`) t ON t.column2 = pt.tag_id +)`, strFormatMap)) + + f.addLeftJoin("performer_tags", "", utils.StrFormat("performer_tags.primaryID = {primaryTable}.id", strFormatMap)) + + addHierarchicalConditionClauses(f, criterion, "performer_tags", "root_tag_id") + } + + if len(criterion.Excludes) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Excludes, tagTable, "tags_relations", "", "", criterion.Depth) + if err != nil { + f.setError(err) + return + } + + clause := utils.StrFormat("{primaryTable}.id NOT IN (SELECT {joinTable}.{joinPrimaryKey} FROM {joinTable} INNER JOIN performers_tags ON {joinTable}.performer_id = performers_tags.performer_id WHERE performers_tags.tag_id IN (SELECT column2 FROM (%s)))", strFormatMap) + f.addWhere(fmt.Sprintf(clause, valuesClause)) + } + } +} + +type stashIDCriterionHandler struct { + c *models.StashIDCriterionInput + stashIDRepository *stashIDRepository + stashIDTableAs string + parentIDCol string +} + +func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder) { + if h.c == nil { + return + } + + stashIDRepo := h.stashIDRepository + t := stashIDRepo.tableName + if h.stashIDTableAs != "" { + t = h.stashIDTableAs + } + + joinClause := fmt.Sprintf("%s.%s = %s", t, stashIDRepo.idColumn, h.parentIDCol) + if h.c.Endpoint != nil && *h.c.Endpoint != "" { + joinClause += fmt.Sprintf(" AND %s.endpoint = '%s'", t, *h.c.Endpoint) + } + + f.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause) + + v := "" + if h.c.StashID != nil { + v = *h.c.StashID + } + + stringCriterionHandler(&models.StringCriterionInput{ + Value: v, + Modifier: h.c.Modifier, + }, t+".stash_id")(ctx, f) +} + +type relatedFilterHandler struct { + relatedIDCol string + relatedRepo repository + relatedHandler criterionHandler + joinFn func(f *filterBuilder) +} + +func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) { + ff := filterBuilderFromHandler(ctx, h.relatedHandler) + if ff.err != nil { + f.setError(ff.err) + return + } + + if ff.empty() { + return + } + + subQuery := h.relatedRepo.newQuery() + selectIDs(&subQuery, subQuery.repository.tableName) + if err := subQuery.addFilter(ff); err != nil { + f.setError(err) + return + } + + if h.joinFn != nil { + h.joinFn(f) + } + + f.addWhere(fmt.Sprintf("%s IN ("+subQuery.toSQL(false)+")", h.relatedIDCol), subQuery.args...) +} diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 90d3706a5..4da53c352 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -61,7 +61,7 @@ func (e *MismatchedSchemaVersionError) Error() string { return fmt.Sprintf("schema version %d is incompatible with required schema version %d", e.CurrentSchemaVersion, e.RequiredSchemaVersion) } -type Database struct { +type storeRepository struct { Blobs *BlobStore File *FileStore Folder *FolderStore @@ -75,6 +75,10 @@ type Database struct { Studio *StudioStore Tag *TagStore Movie *MovieStore +} + +type Database struct { + *storeRepository db *sqlx.DB dbPath string @@ -87,23 +91,32 @@ type Database struct { func NewDatabase() *Database { fileStore := NewFileStore() folderStore := NewFolderStore() + galleryStore := NewGalleryStore(fileStore, folderStore) blobStore := NewBlobStore(BlobStoreOptions{}) + performerStore := NewPerformerStore(blobStore) + studioStore := NewStudioStore(blobStore) + tagStore := NewTagStore(blobStore) - ret := &Database{ + r := &storeRepository{} + *r = storeRepository{ Blobs: blobStore, File: fileStore, Folder: folderStore, - Scene: NewSceneStore(fileStore, blobStore), + Scene: NewSceneStore(r, blobStore), SceneMarker: NewSceneMarkerStore(), - Image: NewImageStore(fileStore), - Gallery: NewGalleryStore(fileStore, folderStore), + Image: NewImageStore(r), + Gallery: galleryStore, GalleryChapter: NewGalleryChapterStore(), - Performer: NewPerformerStore(blobStore), - Studio: NewStudioStore(blobStore), - Tag: NewTagStore(blobStore), + Performer: performerStore, + Studio: studioStore, + Tag: tagStore, Movie: NewMovieStore(blobStore), SavedFilter: NewSavedFilterStore(), - lockChan: make(chan struct{}, 1), + } + + ret := &Database{ + storeRepository: r, + lockChan: make(chan struct{}, 1), } return ret @@ -370,7 +383,7 @@ func (db *Database) Analyze(ctx context.Context) error { } func (db *Database) ExecSQL(ctx context.Context, query string, args []interface{}) (*int64, *int64, error) { - wrapper := dbWrapper{} + wrapper := dbWrapperType{} result, err := wrapper.Exec(ctx, query, args...) if err != nil { @@ -393,7 +406,7 @@ func (db *Database) ExecSQL(ctx context.Context, query string, args []interface{ } func (db *Database) QuerySQL(ctx context.Context, query string, args []interface{}) ([]string, [][]interface{}, error) { - wrapper := dbWrapper{} + wrapper := dbWrapperType{} rows, err := wrapper.QueryxContext(ctx, query, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index c071320c6..6cd74eb34 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -947,7 +947,6 @@ func (qb *FileStore) setQuerySort(query *queryBuilder, findFilter *models.FindFi func (qb *FileStore) captionRepository() *captionRepository { return &captionRepository{ repository: repository{ - tx: qb.tx, tableName: videoCaptionsTable, idColumn: fileIDColumn, }, diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index abf3336a7..f4b5e7e77 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -2,19 +2,55 @@ package sqlite import ( "context" - "database/sql" "errors" "fmt" - "path/filepath" - "regexp" - "strconv" "strings" - "github.com/stashapp/stash/pkg/utils" - "github.com/stashapp/stash/pkg/models" ) +func illegalFilterCombination(type1, type2 string) error { + return fmt.Errorf("cannot have %s and %s in the same filter", type1, type2) +} + +func validateFilterCombination[T any](sf models.OperatorFilter[T]) error { + const and = "AND" + const or = "OR" + const not = "NOT" + + if sf.And != nil { + if sf.Or != nil { + return illegalFilterCombination(and, or) + } + if sf.Not != nil { + return illegalFilterCombination(and, not) + } + } + + if sf.Or != nil { + if sf.Not != nil { + return illegalFilterCombination(or, not) + } + } + + return nil +} + +func handleSubFilter[T any](ctx context.Context, handler criterionHandler, f *filterBuilder, subFilter models.OperatorFilter[T]) { + subQuery := &filterBuilder{} + handler.handle(ctx, subQuery) + + if subFilter.And != nil { + f.and(subQuery) + } + if subFilter.Or != nil { + f.or(subQuery) + } + if subFilter.Not != nil { + f.not(subQuery) + } +} + type sqlClause struct { sql string args []interface{} @@ -54,16 +90,6 @@ func andClauses(clauses ...sqlClause) sqlClause { return joinClauses("AND", clauses...) } -type criterionHandler interface { - handle(ctx context.Context, f *filterBuilder) -} - -type criterionHandlerFunc func(ctx context.Context, f *filterBuilder) - -func (h criterionHandlerFunc) handle(ctx context.Context, f *filterBuilder) { - h(ctx, f) -} - type join struct { table string as string @@ -143,6 +169,16 @@ type filterBuilder struct { err error } +func (f *filterBuilder) empty() bool { + return f == nil || (len(f.whereClauses) == 0 && len(f.joins) == 0 && len(f.havingClauses) == 0 && f.subFilter == nil) +} + +func filterBuilderFromHandler(ctx context.Context, handler criterionHandler) *filterBuilder { + f := &filterBuilder{} + handler.handle(ctx, f) + return f +} + var errSubFilterAlreadySet = errors.New(`sub-filter already set`) // sub-filter operator values @@ -388,876 +424,3 @@ func (f *filterBuilder) andClauses(input []sqlClause) (string, []interface{}) { return "", nil } - -func stringCriterionHandler(c *models.StringCriterionInput, column string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - if modifier := c.Modifier; c.Modifier.IsValid() { - switch modifier { - case models.CriterionModifierIncludes: - f.whereClauses = append(f.whereClauses, getStringSearchClause([]string{column}, c.Value, false)) - case models.CriterionModifierExcludes: - f.whereClauses = append(f.whereClauses, getStringSearchClause([]string{column}, c.Value, true)) - case models.CriterionModifierEquals: - f.addWhere(column+" LIKE ?", c.Value) - case models.CriterionModifierNotEquals: - f.addWhere(column+" NOT LIKE ?", c.Value) - case models.CriterionModifierMatchesRegex: - if _, err := regexp.Compile(c.Value); err != nil { - f.setError(err) - return - } - f.addWhere(fmt.Sprintf("(%s IS NOT NULL AND %[1]s regexp ?)", column), c.Value) - case models.CriterionModifierNotMatchesRegex: - if _, err := regexp.Compile(c.Value); err != nil { - f.setError(err) - 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: - panic("unsupported string filter modifier") - } - } - } - } -} - -func enumCriterionHandler(modifier models.CriterionModifier, values []string, column string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if modifier.IsValid() { - switch modifier { - case models.CriterionModifierIncludes, models.CriterionModifierEquals: - if len(values) > 0 { - f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, false)) - } - case models.CriterionModifierExcludes, models.CriterionModifierNotEquals: - if len(values) > 0 { - f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, true)) - } - case models.CriterionModifierIsNull: - f.addWhere("(" + column + " IS NULL OR TRIM(" + column + ") = '')") - case models.CriterionModifierNotNull: - f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')") - default: - panic("unsupported string filter modifier") - } - } - } -} - -func pathCriterionHandler(c *models.StringCriterionInput, pathColumn string, basenameColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - if addJoinFn != nil { - addJoinFn(f) - } - addWildcards := true - not := false - - if modifier := c.Modifier; c.Modifier.IsValid() { - switch modifier { - case models.CriterionModifierIncludes: - f.whereClauses = append(f.whereClauses, getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not)) - case models.CriterionModifierExcludes: - not = true - f.whereClauses = append(f.whereClauses, getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not)) - case models.CriterionModifierEquals: - addWildcards = false - f.whereClauses = append(f.whereClauses, getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not)) - case models.CriterionModifierNotEquals: - addWildcards = false - not = true - f.whereClauses = append(f.whereClauses, getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not)) - case models.CriterionModifierMatchesRegex: - if _, err := regexp.Compile(c.Value); err != nil { - f.setError(err) - return - } - filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) - f.addWhere(fmt.Sprintf("%s IS NOT NULL AND %s IS NOT NULL AND %s regexp ?", pathColumn, basenameColumn, filepathColumn), c.Value) - case models.CriterionModifierNotMatchesRegex: - if _, err := regexp.Compile(c.Value); err != nil { - f.setError(err) - return - } - filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) - f.addWhere(fmt.Sprintf("%s IS NULL OR %s IS NULL OR %s NOT regexp ?", pathColumn, basenameColumn, filepathColumn), c.Value) - case models.CriterionModifierIsNull: - f.addWhere(fmt.Sprintf("%s IS NULL OR TRIM(%[1]s) = '' OR %s IS NULL OR TRIM(%[2]s) = ''", pathColumn, basenameColumn)) - case models.CriterionModifierNotNull: - f.addWhere(fmt.Sprintf("%s IS NOT NULL AND TRIM(%[1]s) != '' AND %s IS NOT NULL AND TRIM(%[2]s) != ''", pathColumn, basenameColumn)) - default: - panic("unsupported string filter modifier") - } - } - } - } -} - -func getPathSearchClause(pathColumn, basenameColumn, p string, addWildcards, not bool) sqlClause { - if addWildcards { - p = "%" + p + "%" - } - - filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) - ret := makeClause(fmt.Sprintf("%s LIKE ?", filepathColumn), p) - - if not { - ret = ret.not() - } - - return ret -} - -// getPathSearchClauseMany splits the query string p on whitespace -// Used for backwards compatibility for the includes/excludes modifiers -func getPathSearchClauseMany(pathColumn, basenameColumn, p string, addWildcards, not bool) sqlClause { - q := strings.TrimSpace(p) - trimmedQuery := strings.Trim(q, "\"") - - if trimmedQuery == q { - q = regexp.MustCompile(`\s+`).ReplaceAllString(q, " ") - queryWords := strings.Split(q, " ") - - var ret []sqlClause - // Search for any word - for _, word := range queryWords { - ret = append(ret, getPathSearchClause(pathColumn, basenameColumn, word, addWildcards, not)) - } - - if !not { - return orClauses(ret...) - } - - return andClauses(ret...) - } - - return getPathSearchClause(pathColumn, basenameColumn, trimmedQuery, addWildcards, not) -} - -func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - if addJoinFn != nil { - addJoinFn(f) - } - clause, args := getIntCriterionWhereClause(column, *c) - f.addWhere(clause, args...) - } - } -} - -func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - if addJoinFn != nil { - addJoinFn(f) - } - clause, args := getFloatCriterionWhereClause(column, *c) - f.addWhere(clause, args...) - } - } -} - -func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - if addJoinFn != nil { - addJoinFn(f) - } - var v string - if *c { - v = "1" - } else { - v = "0" - } - - f.addWhere(column + " = " + v) - } - } -} - -func dateCriterionHandler(c *models.DateCriterionInput, column string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - clause, args := getDateCriterionWhereClause(column, *c) - f.addWhere(clause, args...) - } - } -} - -func timestampCriterionHandler(c *models.TimestampCriterionInput, column string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - clause, args := getTimestampCriterionWhereClause(column, *c) - f.addWhere(clause, args...) - } - } -} - -// handle for MultiCriterion where there is a join table between the new -// objects -type joinedMultiCriterionHandlerBuilder struct { - // table containing the primary objects - primaryTable string - // table joining primary and foreign objects - joinTable string - // alias for join table, if required - joinAs string - // foreign key of the primary object on the join table - primaryFK string - // foreign key of the foreign object on the join table - foreignFK string - - addJoinTable func(f *filterBuilder) -} - -func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - // make local copy so we can modify it - criterion := *c - - joinAlias := m.joinAs - if joinAlias == "" { - joinAlias = m.joinTable - } - - if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { - var notClause string - if criterion.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - m.addJoinTable(f) - - f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ - "table": joinAlias, - "column": m.foreignFK, - "not": notClause, - })) - return - } - - if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { - return - } - - // combine excludes if excludes modifier is selected - if criterion.Modifier == models.CriterionModifierExcludes { - criterion.Modifier = models.CriterionModifierIncludesAll - criterion.Excludes = append(criterion.Excludes, criterion.Value...) - criterion.Value = nil - } - - if len(criterion.Value) > 0 { - whereClause := "" - havingClause := "" - - var args []interface{} - for _, tagID := range criterion.Value { - args = append(args, tagID) - } - - switch criterion.Modifier { - case models.CriterionModifierIncludes: - // includes any of the provided ids - m.addJoinTable(f) - whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) - case models.CriterionModifierEquals: - // includes only the provided ids - m.addJoinTable(f) - whereClause = utils.StrFormat("{joinAlias}.{foreignFK} IN {inBinding} AND (SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{ - "joinAlias": joinAlias, - "foreignFK": m.foreignFK, - "inBinding": getInBinding(len(criterion.Value)), - "joinTable": m.joinTable, - "primaryFK": m.primaryFK, - "primaryTable": m.primaryTable, - }) - havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) - args = append(args, len(criterion.Value)) - case models.CriterionModifierNotEquals: - f.setError(fmt.Errorf("not equals modifier is not supported for multi criterion input")) - case models.CriterionModifierIncludesAll: - // includes all of the provided ids - m.addJoinTable(f) - whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) - havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) - } - - f.addWhere(whereClause, args...) - f.addHaving(havingClause) - } - - if len(criterion.Excludes) > 0 { - var args []interface{} - for _, tagID := range criterion.Excludes { - args = append(args, tagID) - } - - // excludes all of the provided ids - // need to use actual join table name for this - // .id NOT IN (select . from where . in ) - whereClause := fmt.Sprintf("%[1]s.id NOT IN (SELECT %[3]s.%[2]s from %[3]s where %[3]s.%[4]s in %[5]s)", m.primaryTable, m.primaryFK, m.joinTable, m.foreignFK, getInBinding(len(criterion.Excludes))) - - f.addWhere(whereClause, args...) - } - } - } -} - -type multiCriterionHandlerBuilder struct { - primaryTable string - foreignTable string - joinTable string - primaryFK string - foreignFK string - - // function that will be called to perform any necessary joins - addJoinsFunc func(f *filterBuilder) -} - -func (m *multiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { - if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { - var notClause string - if criterion.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - table := m.primaryTable - if m.joinTable != "" { - table = m.joinTable - f.addLeftJoin(table, "", fmt.Sprintf("%s.%s = %s.id", table, m.primaryFK, m.primaryTable)) - } - - f.addWhere(fmt.Sprintf("%s.%s IS %s NULL", table, m.foreignFK, notClause)) - return - } - - if len(criterion.Value) == 0 { - return - } - - var args []interface{} - for _, tagID := range criterion.Value { - args = append(args, tagID) - } - - if m.addJoinsFunc != nil { - m.addJoinsFunc(f) - } - - whereClause, havingClause := getMultiCriterionClause(m.primaryTable, m.foreignTable, m.joinTable, m.primaryFK, m.foreignFK, criterion) - f.addWhere(whereClause, args...) - f.addHaving(havingClause) - } - } -} - -type countCriterionHandlerBuilder struct { - primaryTable string - joinTable string - primaryFK string -} - -func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { - clause, args := getCountCriterionClause(m.primaryTable, m.joinTable, m.primaryFK, *criterion) - - f.addWhere(clause, args...) - } - } -} - -// handler for StringCriterion for string list fields -type stringListCriterionHandlerBuilder struct { - // table joining primary and foreign objects - joinTable string - // string field on the join table - stringColumn string - - addJoinTable func(f *filterBuilder) -} - -func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { - m.addJoinTable(f) - - stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(ctx, f) - } - } -} - -func studioCriterionHandler(primaryTable string, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if studios == nil { - return - } - - studiosCopy := *studios - switch studiosCopy.Modifier { - case models.CriterionModifierEquals: - studiosCopy.Modifier = models.CriterionModifierIncludesAll - case models.CriterionModifierNotEquals: - studiosCopy.Modifier = models.CriterionModifierExcludes - } - - hh := hierarchicalMultiCriterionHandlerBuilder{ - tx: dbWrapper{}, - - primaryTable: primaryTable, - foreignTable: studioTable, - foreignFK: studioIDColumn, - parentFK: "parent_id", - } - - hh.handler(&studiosCopy)(ctx, f) - } -} - -type hierarchicalMultiCriterionHandlerBuilder struct { - tx dbWrapper - - primaryTable string - foreignTable string - foreignFK string - - parentFK string - childFK string - relationsTable string -} - -func getHierarchicalValues(ctx context.Context, tx dbWrapper, values []string, table, relationsTable, parentFK string, childFK string, depth *int) (string, error) { - var args []interface{} - - if parentFK == "" { - parentFK = "parent_id" - } - if childFK == "" { - childFK = "child_id" - } - - depthVal := 0 - if depth != nil { - depthVal = *depth - } - - if depthVal == 0 { - valid := true - var valuesClauses []string - for _, value := range values { - id, err := strconv.Atoi(value) - // In case of invalid value just run the query. - // Building VALUES() based on provided values just saves a query when depth is 0. - if err != nil { - valid = false - break - } - - valuesClauses = append(valuesClauses, fmt.Sprintf("(%d,%d)", id, id)) - } - - if valid { - return "VALUES" + strings.Join(valuesClauses, ","), nil - } - } - - for _, value := range values { - args = append(args, value) - } - inCount := len(args) - - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) - } - - withClauseMap := utils.StrFormatMap{ - "table": table, - "relationsTable": relationsTable, - "inBinding": getInBinding(inCount), - "recursiveSelect": "", - "parentFK": parentFK, - "childFK": childFK, - "depthCondition": depthCondition, - "unionClause": "", - } - - if relationsTable != "" { - withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.{childFK}, depth + 1 FROM {relationsTable} AS c -INNER JOIN items as p ON c.{parentFK} = p.item_id -`, withClauseMap) - } else { - withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.id, depth + 1 FROM {table} as c -INNER JOIN items as p ON c.{parentFK} = p.item_id -`, withClauseMap) - } - - if depthVal != 0 { - withClauseMap["unionClause"] = utils.StrFormat(` -UNION {recursiveSelect} {depthCondition} -`, withClauseMap) - } - - withClause := utils.StrFormat(`items AS ( -SELECT id as root_id, id as item_id, 0 as depth FROM {table} -WHERE id in {inBinding} -{unionClause}) -`, withClauseMap) - - query := fmt.Sprintf("WITH RECURSIVE %s SELECT 'VALUES' || GROUP_CONCAT('(' || root_id || ', ' || item_id || ')') AS val FROM items", withClause) - - var valuesClause sql.NullString - err := tx.Get(ctx, &valuesClause, query, args...) - if err != nil { - return "", fmt.Errorf("failed to get hierarchical values: %w", err) - } - - // if no values are found, just return a values string with the values only - if !valuesClause.Valid { - for i, value := range values { - values[i] = fmt.Sprintf("(%s, %s)", value, value) - } - valuesClause.String = "VALUES" + strings.Join(values, ",") - } - - return valuesClause.String, nil -} - -func addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { - switch criterion.Modifier { - case models.CriterionModifierIncludes: - f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) - case models.CriterionModifierIncludesAll: - f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) - f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", table, idColumn, len(criterion.Value))) - case models.CriterionModifierExcludes: - f.addWhere(fmt.Sprintf("%s.%s IS NULL", table, idColumn)) - } -} - -func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - // make a copy so we don't modify the original - criterion := *c - - // don't support equals/not equals - if criterion.Modifier == models.CriterionModifierEquals || criterion.Modifier == models.CriterionModifierNotEquals { - f.setError(fmt.Errorf("modifier %s is not supported for hierarchical multi criterion", criterion.Modifier)) - return - } - - if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { - var notClause string - if criterion.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ - "table": m.primaryTable, - "column": m.foreignFK, - "not": notClause, - })) - return - } - - if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { - return - } - - // combine excludes if excludes modifier is selected - if criterion.Modifier == models.CriterionModifierExcludes { - criterion.Modifier = models.CriterionModifierIncludesAll - criterion.Excludes = append(criterion.Excludes, criterion.Value...) - criterion.Value = nil - } - - if len(criterion.Value) > 0 { - valuesClause, err := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) - if err != nil { - f.setError(err) - return - } - - switch criterion.Modifier { - case models.CriterionModifierIncludes: - f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) - case models.CriterionModifierIncludesAll: - f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) - f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", m.primaryTable, m.foreignFK, len(criterion.Value))) - } - } - - if len(criterion.Excludes) > 0 { - valuesClause, err := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) - if err != nil { - f.setError(err) - return - } - - f.addWhere(fmt.Sprintf("%s.%s NOT IN (SELECT column2 FROM (%s)) OR %[1]s.%[2]s IS NULL", m.primaryTable, m.foreignFK, valuesClause)) - } - } - } -} - -type joinedHierarchicalMultiCriterionHandlerBuilder struct { - tx dbWrapper - - primaryTable string - primaryKey string - foreignTable string - foreignFK string - - parentFK string - childFK string - relationsTable string - - joinAs string - joinTable string - primaryFK string -} - -func (m *joinedHierarchicalMultiCriterionHandlerBuilder) addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { - primaryKey := m.primaryKey - if primaryKey == "" { - primaryKey = "id" - } - - switch criterion.Modifier { - case models.CriterionModifierEquals: - // includes only the provided ids - f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) - f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", table, idColumn, len(criterion.Value))) - f.addWhere(utils.StrFormat("(SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.{primaryKey}) = ?", utils.StrFormatMap{ - "joinTable": m.joinTable, - "primaryFK": m.primaryFK, - "primaryTable": m.primaryTable, - "primaryKey": primaryKey, - }), len(criterion.Value)) - case models.CriterionModifierNotEquals: - f.setError(fmt.Errorf("not equals modifier is not supported for hierarchical multi criterion input")) - default: - addHierarchicalConditionClauses(f, criterion, table, idColumn) - } -} - -func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - // make a copy so we don't modify the original - criterion := *c - joinAlias := m.joinAs - primaryKey := m.primaryKey - if primaryKey == "" { - primaryKey = "id" - } - - if criterion.Modifier == models.CriterionModifierEquals && criterion.Depth != nil && *criterion.Depth != 0 { - f.setError(fmt.Errorf("depth is not supported for equals modifier in hierarchical multi criterion input")) - return - } - - if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { - var notClause string - if criterion.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin(m.joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.%s", joinAlias, m.primaryFK, m.primaryTable, primaryKey)) - - f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ - "table": joinAlias, - "column": m.foreignFK, - "not": notClause, - })) - return - } - - // combine excludes if excludes modifier is selected - if criterion.Modifier == models.CriterionModifierExcludes { - criterion.Modifier = models.CriterionModifierIncludesAll - criterion.Excludes = append(criterion.Excludes, criterion.Value...) - criterion.Value = nil - } - - if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { - return - } - - if len(criterion.Value) > 0 { - valuesClause, err := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) - if err != nil { - f.setError(err) - return - } - - joinTable := utils.StrFormat(`( - SELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j - INNER JOIN ({valuesClause}) AS d ON j.{foreignFK} = d.column2 - ) - `, utils.StrFormatMap{ - "joinTable": m.joinTable, - "foreignFK": m.foreignFK, - "valuesClause": valuesClause, - }) - - f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.%s", joinAlias, m.primaryFK, m.primaryTable, primaryKey)) - - m.addHierarchicalConditionClauses(f, criterion, joinAlias, "root_id") - } - - if len(criterion.Excludes) > 0 { - valuesClause, err := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) - if err != nil { - f.setError(err) - return - } - - joinTable := utils.StrFormat(`( - SELECT j2.*, e.column1 AS root_id, e.column2 AS item_id FROM {joinTable} AS j2 - INNER JOIN ({valuesClause}) AS e ON j2.{foreignFK} = e.column2 - ) - `, utils.StrFormatMap{ - "joinTable": m.joinTable, - "foreignFK": m.foreignFK, - "valuesClause": valuesClause, - }) - - joinAlias2 := joinAlias + "2" - - f.addLeftJoin(joinTable, joinAlias2, fmt.Sprintf("%s.%s = %s.%s", joinAlias2, m.primaryFK, m.primaryTable, primaryKey)) - - // modify for exclusion - criterionCopy := criterion - criterionCopy.Modifier = models.CriterionModifierExcludes - criterionCopy.Value = c.Excludes - - m.addHierarchicalConditionClauses(f, criterionCopy, joinAlias2, "root_id") - } - } - } -} - -type joinedPerformerTagsHandler struct { - criterion *models.HierarchicalMultiCriterionInput - - primaryTable string // eg scenes - joinTable string // eg performers_scenes - joinPrimaryKey string // eg scene_id -} - -func (h *joinedPerformerTagsHandler) handle(ctx context.Context, f *filterBuilder) { - tags := h.criterion - - if tags != nil { - criterion := tags.CombineExcludes() - - // validate the modifier - switch criterion.Modifier { - case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: - // valid - default: - f.setError(fmt.Errorf("invalid modifier %s for performer tags", criterion.Modifier)) - } - - strFormatMap := utils.StrFormatMap{ - "primaryTable": h.primaryTable, - "joinTable": h.joinTable, - "joinPrimaryKey": h.joinPrimaryKey, - "inBinding": getInBinding(len(criterion.Value)), - } - - if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { - var notClause string - if criterion.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin(h.joinTable, "", utils.StrFormat("{primaryTable}.id = {joinTable}.{joinPrimaryKey}", strFormatMap)) - f.addLeftJoin("performers_tags", "", utils.StrFormat("{joinTable}.performer_id = performers_tags.performer_id", strFormatMap)) - - f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause)) - return - } - - if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { - return - } - - if len(criterion.Value) > 0 { - valuesClause, err := getHierarchicalValues(ctx, dbWrapper{}, criterion.Value, tagTable, "tags_relations", "", "", criterion.Depth) - if err != nil { - f.setError(err) - return - } - - f.addWith(utils.StrFormat(`performer_tags AS ( -SELECT ps.{joinPrimaryKey} as primaryID, t.column1 AS root_tag_id FROM {joinTable} ps -INNER JOIN performers_tags pt ON pt.performer_id = ps.performer_id -INNER JOIN (`+valuesClause+`) t ON t.column2 = pt.tag_id -)`, strFormatMap)) - - f.addLeftJoin("performer_tags", "", utils.StrFormat("performer_tags.primaryID = {primaryTable}.id", strFormatMap)) - - addHierarchicalConditionClauses(f, criterion, "performer_tags", "root_tag_id") - } - - if len(criterion.Excludes) > 0 { - valuesClause, err := getHierarchicalValues(ctx, dbWrapper{}, criterion.Excludes, tagTable, "tags_relations", "", "", criterion.Depth) - if err != nil { - f.setError(err) - return - } - - clause := utils.StrFormat("{primaryTable}.id NOT IN (SELECT {joinTable}.{joinPrimaryKey} FROM {joinTable} INNER JOIN performers_tags ON {joinTable}.performer_id = performers_tags.performer_id WHERE performers_tags.tag_id IN (SELECT column2 FROM (%s)))", strFormatMap) - f.addWhere(fmt.Sprintf(clause, valuesClause)) - } - } -} - -type stashIDCriterionHandler struct { - c *models.StashIDCriterionInput - stashIDRepository *stashIDRepository - stashIDTableAs string - parentIDCol string -} - -func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder) { - if h.c == nil { - return - } - - stashIDRepo := h.stashIDRepository - t := stashIDRepo.tableName - if h.stashIDTableAs != "" { - t = h.stashIDTableAs - } - - joinClause := fmt.Sprintf("%s.%s = %s", t, stashIDRepo.idColumn, h.parentIDCol) - if h.c.Endpoint != nil && *h.c.Endpoint != "" { - joinClause += fmt.Sprintf(" AND %s.endpoint = '%s'", t, *h.c.Endpoint) - } - - f.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause) - - v := "" - if h.c.StashID != nil { - v = *h.c.StashID - } - - stringCriterionHandler(&models.StringCriterionInput{ - Value: v, - Modifier: h.c.Modifier, - }, t+".stash_id")(ctx, f) -} diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 7ddb514d0..b7f7552c2 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "path/filepath" - "regexp" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" @@ -113,9 +112,75 @@ func (r *galleryRowRecord) fromPartial(o models.GalleryPartial) { r.setTimestamp("updated_at", o.UpdatedAt) } -type GalleryStore struct { +type galleryRepositoryType struct { repository + performers joinRepository + images joinRepository + tags joinRepository + scenes joinRepository + files filesRepository +} +func (r *galleryRepositoryType) addGalleriesFilesTable(f *filterBuilder) { + f.addLeftJoin(galleriesFilesTable, "", "galleries_files.gallery_id = galleries.id") +} + +func (r *galleryRepositoryType) addFilesTable(f *filterBuilder) { + r.addGalleriesFilesTable(f) + f.addLeftJoin(fileTable, "", "galleries_files.file_id = files.id") +} + +func (r *galleryRepositoryType) addFoldersTable(f *filterBuilder) { + r.addFilesTable(f) + f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") +} + +var ( + galleryRepository = galleryRepositoryType{ + repository: repository{ + tableName: galleryTable, + idColumn: idColumn, + }, + performers: joinRepository{ + repository: repository{ + tableName: performersGalleriesTable, + idColumn: galleryIDColumn, + }, + fkColumn: "performer_id", + }, + tags: joinRepository{ + repository: repository{ + tableName: galleriesTagsTable, + idColumn: galleryIDColumn, + }, + fkColumn: "tag_id", + foreignTable: tagTable, + orderBy: "tags.name ASC", + }, + images: joinRepository{ + repository: repository{ + tableName: galleriesImagesTable, + idColumn: galleryIDColumn, + }, + fkColumn: "image_id", + }, + scenes: joinRepository{ + repository: repository{ + tableName: galleriesScenesTable, + idColumn: galleryIDColumn, + }, + fkColumn: sceneIDColumn, + }, + files: filesRepository{ + repository: repository{ + tableName: galleriesFilesTable, + idColumn: galleryIDColumn, + }, + }, + } +) + +type GalleryStore struct { tableMgr *table fileStore *FileStore @@ -124,10 +189,6 @@ type GalleryStore struct { func NewGalleryStore(fileStore *FileStore, folderStore *FolderStore) *GalleryStore { return &GalleryStore{ - repository: repository{ - tableName: galleryTable, - idColumn: idColumn, - }, tableMgr: galleryTableMgr, fileStore: fileStore, folderStore: folderStore, @@ -309,7 +370,7 @@ func (qb *GalleryStore) Destroy(ctx context.Context, id int) error { } func (qb *GalleryStore) GetFiles(ctx context.Context, id int) ([]models.File, error) { - fileIDs, err := qb.filesRepository().get(ctx, id) + fileIDs, err := galleryRepository.files.get(ctx, id) if err != nil { return nil, err } @@ -328,7 +389,7 @@ func (qb *GalleryStore) GetFiles(ctx context.Context, id int) ([]models.File, er func (qb *GalleryStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) { const primaryOnly = false - return qb.filesRepository().getMany(ctx, ids, primaryOnly) + return galleryRepository.files.getMany(ctx, ids, primaryOnly) } // returns nil, nil if not found @@ -617,116 +678,6 @@ func (qb *GalleryStore) All(ctx context.Context) ([]*models.Gallery, error) { return qb.getMany(ctx, qb.selectDataset()) } -func (qb *GalleryStore) validateFilter(galleryFilter *models.GalleryFilterType) error { - const and = "AND" - const or = "OR" - const not = "NOT" - - if galleryFilter.And != nil { - if galleryFilter.Or != nil { - return illegalFilterCombination(and, or) - } - if galleryFilter.Not != nil { - return illegalFilterCombination(and, not) - } - - return qb.validateFilter(galleryFilter.And) - } - - if galleryFilter.Or != nil { - if galleryFilter.Not != nil { - return illegalFilterCombination(or, not) - } - - return qb.validateFilter(galleryFilter.Or) - } - - if galleryFilter.Not != nil { - return qb.validateFilter(galleryFilter.Not) - } - - return nil -} - -func (qb *GalleryStore) makeFilter(ctx context.Context, galleryFilter *models.GalleryFilterType) *filterBuilder { - query := &filterBuilder{} - - if galleryFilter.And != nil { - query.and(qb.makeFilter(ctx, galleryFilter.And)) - } - if galleryFilter.Or != nil { - query.or(qb.makeFilter(ctx, galleryFilter.Or)) - } - if galleryFilter.Not != nil { - query.not(qb.makeFilter(ctx, galleryFilter.Not)) - } - - query.handleCriterion(ctx, intCriterionHandler(galleryFilter.ID, "galleries.id", nil)) - query.handleCriterion(ctx, stringCriterionHandler(galleryFilter.Title, "galleries.title")) - query.handleCriterion(ctx, stringCriterionHandler(galleryFilter.Code, "galleries.code")) - query.handleCriterion(ctx, stringCriterionHandler(galleryFilter.Details, "galleries.details")) - query.handleCriterion(ctx, stringCriterionHandler(galleryFilter.Photographer, "galleries.photographer")) - - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if galleryFilter.Checksum != nil { - qb.addGalleriesFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_md5", "galleries_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") - } - - stringCriterionHandler(galleryFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) - })) - - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if galleryFilter.IsZip != nil { - qb.addGalleriesFilesTable(f) - if *galleryFilter.IsZip { - - f.addWhere("galleries_files.file_id IS NOT NULL") - } else { - f.addWhere("galleries_files.file_id IS NULL") - } - } - })) - - query.handleCriterion(ctx, qb.galleryPathCriterionHandler(galleryFilter.Path)) - query.handleCriterion(ctx, galleryFileCountCriterionHandler(qb, galleryFilter.FileCount)) - query.handleCriterion(ctx, intCriterionHandler(galleryFilter.Rating100, "galleries.rating", nil)) - query.handleCriterion(ctx, galleryURLsCriterionHandler(galleryFilter.URL)) - query.handleCriterion(ctx, boolCriterionHandler(galleryFilter.Organized, "galleries.organized", nil)) - query.handleCriterion(ctx, galleryIsMissingCriterionHandler(qb, galleryFilter.IsMissing)) - query.handleCriterion(ctx, galleryTagsCriterionHandler(qb, galleryFilter.Tags)) - query.handleCriterion(ctx, galleryTagCountCriterionHandler(qb, galleryFilter.TagCount)) - query.handleCriterion(ctx, galleryPerformersCriterionHandler(qb, galleryFilter.Performers)) - query.handleCriterion(ctx, galleryPerformerCountCriterionHandler(qb, galleryFilter.PerformerCount)) - query.handleCriterion(ctx, hasChaptersCriterionHandler(galleryFilter.HasChapters)) - query.handleCriterion(ctx, galleryScenesCriterionHandler(qb, galleryFilter.Scenes)) - query.handleCriterion(ctx, studioCriterionHandler(galleryTable, galleryFilter.Studios)) - query.handleCriterion(ctx, galleryPerformerTagsCriterionHandler(qb, galleryFilter.PerformerTags)) - query.handleCriterion(ctx, galleryAverageResolutionCriterionHandler(qb, galleryFilter.AverageResolution)) - query.handleCriterion(ctx, galleryImageCountCriterionHandler(qb, galleryFilter.ImageCount)) - query.handleCriterion(ctx, galleryPerformerFavoriteCriterionHandler(galleryFilter.PerformerFavorite)) - query.handleCriterion(ctx, galleryPerformerAgeCriterionHandler(galleryFilter.PerformerAge)) - query.handleCriterion(ctx, dateCriterionHandler(galleryFilter.Date, "galleries.date")) - query.handleCriterion(ctx, timestampCriterionHandler(galleryFilter.CreatedAt, "galleries.created_at")) - query.handleCriterion(ctx, timestampCriterionHandler(galleryFilter.UpdatedAt, "galleries.updated_at")) - - return query -} - -func (qb *GalleryStore) addGalleriesFilesTable(f *filterBuilder) { - f.addLeftJoin(galleriesFilesTable, "", "galleries_files.gallery_id = galleries.id") -} - -func (qb *GalleryStore) addFilesTable(f *filterBuilder) { - qb.addGalleriesFilesTable(f) - f.addLeftJoin(fileTable, "", "galleries_files.file_id = files.id") -} - -func (qb *GalleryStore) addFoldersTable(f *filterBuilder) { - qb.addFilesTable(f) - f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") -} - func (qb *GalleryStore) makeQuery(ctx context.Context, galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if galleryFilter == nil { galleryFilter = &models.GalleryFilterType{} @@ -735,7 +686,7 @@ func (qb *GalleryStore) makeQuery(ctx context.Context, galleryFilter *models.Gal findFilter = &models.FindFilterType{} } - query := qb.newQuery() + query := galleryRepository.newQuery() distinctIDs(&query, galleryTable) if q := findFilter.Q; q != nil && *q != "" { @@ -773,10 +724,9 @@ func (qb *GalleryStore) makeQuery(ctx context.Context, galleryFilter *models.Gal query.parseQueryString(searchColumns, *q) } - if err := qb.validateFilter(galleryFilter); err != nil { - return nil, err - } - filter := qb.makeFilter(ctx, galleryFilter) + filter := filterBuilderFromHandler(ctx, &galleryFilterHandler{ + galleryFilter: galleryFilter, + }) if err := query.addFilter(filter); err != nil { return nil, err @@ -818,290 +768,6 @@ func (qb *GalleryStore) QueryCount(ctx context.Context, galleryFilter *models.Ga return query.executeCount(ctx) } -func galleryURLsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { - h := stringListCriterionHandlerBuilder{ - joinTable: galleriesURLsTable, - stringColumn: galleriesURLColumn, - addJoinTable: func(f *filterBuilder) { - galleriesURLsTableMgr.join(f, "", "galleries.id") - }, - } - - return h.handler(url) -} - -func (qb *GalleryStore) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { - return multiCriterionHandlerBuilder{ - primaryTable: galleryTable, - foreignTable: foreignTable, - joinTable: joinTable, - primaryFK: galleryIDColumn, - foreignFK: foreignFK, - addJoinsFunc: addJoinsFunc, - } -} - -func (qb *GalleryStore) galleryPathCriterionHandler(c *models.StringCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - qb.addFoldersTable(f) - f.addLeftJoin(folderTable, "gallery_folder", "galleries.folder_id = gallery_folder.id") - - const pathColumn = "folders.path" - const basenameColumn = "files.basename" - const folderPathColumn = "gallery_folder.path" - - addWildcards := true - not := false - - if modifier := c.Modifier; c.Modifier.IsValid() { - switch modifier { - case models.CriterionModifierIncludes: - clause := getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not) - clause2 := getStringSearchClause([]string{folderPathColumn}, c.Value, false) - f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) - case models.CriterionModifierExcludes: - not = true - clause := getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not) - clause2 := getStringSearchClause([]string{folderPathColumn}, c.Value, true) - f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) - case models.CriterionModifierEquals: - addWildcards = false - clause := getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not) - clause2 := makeClause(folderPathColumn+" LIKE ?", c.Value) - f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) - case models.CriterionModifierNotEquals: - addWildcards = false - not = true - clause := getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not) - clause2 := makeClause(folderPathColumn+" NOT LIKE ?", c.Value) - f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) - case models.CriterionModifierMatchesRegex: - if _, err := regexp.Compile(c.Value); err != nil { - f.setError(err) - return - } - filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) - clause := makeClause(fmt.Sprintf("%s IS NOT NULL AND %s IS NOT NULL AND %s regexp ?", pathColumn, basenameColumn, filepathColumn), c.Value) - clause2 := makeClause(fmt.Sprintf("%s IS NOT NULL AND %[1]s regexp ?", folderPathColumn), c.Value) - f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) - case models.CriterionModifierNotMatchesRegex: - if _, err := regexp.Compile(c.Value); err != nil { - f.setError(err) - return - } - filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) - f.addWhere(fmt.Sprintf("%s IS NULL OR %s IS NULL OR %s NOT regexp ?", pathColumn, basenameColumn, filepathColumn), c.Value) - f.addWhere(fmt.Sprintf("%s IS NULL OR %[1]s NOT regexp ?", folderPathColumn), c.Value) - case models.CriterionModifierIsNull: - f.addWhere(fmt.Sprintf("%s IS NULL OR TRIM(%[1]s) = '' OR %s IS NULL OR TRIM(%[2]s) = ''", pathColumn, basenameColumn)) - f.addWhere(fmt.Sprintf("%s IS NULL OR TRIM(%[1]s) = ''", folderPathColumn)) - case models.CriterionModifierNotNull: - clause := makeClause(fmt.Sprintf("%s IS NOT NULL AND TRIM(%[1]s) != '' AND %s IS NOT NULL AND TRIM(%[2]s) != ''", pathColumn, basenameColumn)) - clause2 := makeClause(fmt.Sprintf("%s IS NOT NULL AND TRIM(%[1]s) != ''", folderPathColumn)) - f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) - default: - panic("unsupported string filter modifier") - } - } - } - } -} - -func galleryFileCountCriterionHandler(qb *GalleryStore, fileCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: galleryTable, - joinTable: galleriesFilesTable, - primaryFK: galleryIDColumn, - } - - return h.handler(fileCount) -} - -func galleryIsMissingCriterionHandler(qb *GalleryStore, isMissing *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if isMissing != nil && *isMissing != "" { - switch *isMissing { - case "url": - galleriesURLsTableMgr.join(f, "", "galleries.id") - f.addWhere("gallery_urls.url IS NULL") - case "scenes": - f.addLeftJoin("scenes_galleries", "scenes_join", "scenes_join.gallery_id = galleries.id") - f.addWhere("scenes_join.gallery_id IS NULL") - case "studio": - f.addWhere("galleries.studio_id IS NULL") - case "performers": - qb.performersRepository().join(f, "performers_join", "galleries.id") - f.addWhere("performers_join.gallery_id IS NULL") - case "date": - f.addWhere("galleries.date IS NULL OR galleries.date IS \"\"") - case "tags": - qb.tagsRepository().join(f, "tags_join", "galleries.id") - f.addWhere("tags_join.gallery_id IS NULL") - default: - f.addWhere("(galleries." + *isMissing + " IS NULL OR TRIM(galleries." + *isMissing + ") = '')") - } - } - } -} - -func galleryTagsCriterionHandler(qb *GalleryStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := joinedHierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: galleryTable, - foreignTable: tagTable, - foreignFK: "tag_id", - - relationsTable: "tags_relations", - joinAs: "image_tag", - joinTable: galleriesTagsTable, - primaryFK: galleryIDColumn, - } - - return h.handler(tags) -} - -func galleryTagCountCriterionHandler(qb *GalleryStore, tagCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: galleryTable, - joinTable: galleriesTagsTable, - primaryFK: galleryIDColumn, - } - - return h.handler(tagCount) -} - -func galleryScenesCriterionHandler(qb *GalleryStore, scenes *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - qb.scenesRepository().join(f, "", "galleries.id") - f.addLeftJoin("scenes", "", "scenes_galleries.scene_id = scenes.id") - } - h := qb.getMultiCriterionHandlerBuilder(sceneTable, galleriesScenesTable, "scene_id", addJoinsFunc) - return h.handler(scenes) -} - -func galleryPerformersCriterionHandler(qb *GalleryStore, performers *models.MultiCriterionInput) criterionHandlerFunc { - h := joinedMultiCriterionHandlerBuilder{ - primaryTable: galleryTable, - joinTable: performersGalleriesTable, - joinAs: "performers_join", - primaryFK: galleryIDColumn, - foreignFK: performerIDColumn, - - addJoinTable: func(f *filterBuilder) { - qb.performersRepository().join(f, "performers_join", "galleries.id") - }, - } - - return h.handler(performers) -} - -func galleryPerformerCountCriterionHandler(qb *GalleryStore, performerCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: galleryTable, - joinTable: performersGalleriesTable, - primaryFK: galleryIDColumn, - } - - return h.handler(performerCount) -} - -func galleryImageCountCriterionHandler(qb *GalleryStore, imageCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: galleryTable, - joinTable: galleriesImagesTable, - primaryFK: galleryIDColumn, - } - - return h.handler(imageCount) -} - -func hasChaptersCriterionHandler(hasChapters *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if hasChapters != nil { - f.addLeftJoin("galleries_chapters", "", "galleries_chapters.gallery_id = galleries.id") - if *hasChapters == "true" { - f.addHaving("count(galleries_chapters.gallery_id) > 0") - } else { - f.addWhere("galleries_chapters.id IS NULL") - } - } - } -} - -func galleryPerformerTagsCriterionHandler(qb *GalleryStore, tags *models.HierarchicalMultiCriterionInput) criterionHandler { - return &joinedPerformerTagsHandler{ - criterion: tags, - primaryTable: galleryTable, - joinTable: performersGalleriesTable, - joinPrimaryKey: galleryIDColumn, - } -} - -func galleryPerformerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performerfavorite != nil { - f.addLeftJoin("performers_galleries", "", "galleries.id = performers_galleries.gallery_id") - - if *performerfavorite { - // contains at least one favorite - f.addLeftJoin("performers", "", "performers.id = performers_galleries.performer_id") - f.addWhere("performers.favorite = 1") - } else { - // contains zero favorites - f.addLeftJoin(`(SELECT performers_galleries.gallery_id as id FROM performers_galleries -JOIN performers ON performers.id = performers_galleries.performer_id -GROUP BY performers_galleries.gallery_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "galleries.id = nofaves.id") - f.addWhere("performers_galleries.gallery_id IS NULL OR nofaves.id IS NOT NULL") - } - } - } -} - -func galleryPerformerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performerAge != nil { - f.addInnerJoin("performers_galleries", "", "galleries.id = performers_galleries.gallery_id") - f.addInnerJoin("performers", "", "performers_galleries.performer_id = performers.id") - - f.addWhere("galleries.date != '' AND performers.birthdate != ''") - f.addWhere("galleries.date IS NOT NULL AND performers.birthdate IS NOT NULL") - - ageCalc := "cast(strftime('%Y.%m%d', galleries.date) - strftime('%Y.%m%d', performers.birthdate) as int)" - whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) - f.addWhere(whereClause, args...) - } - } -} - -func galleryAverageResolutionCriterionHandler(qb *GalleryStore, resolution *models.ResolutionCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if resolution != nil && resolution.Value.IsValid() { - qb.imagesRepository().join(f, "images_join", "galleries.id") - f.addLeftJoin("images", "", "images_join.image_id = images.id") - f.addLeftJoin("images_files", "", "images.id = images_files.image_id") - f.addLeftJoin("image_files", "", "images_files.file_id = image_files.file_id") - - min := resolution.Value.GetMinResolution() - max := resolution.Value.GetMaxResolution() - - const widthHeight = "avg(MIN(image_files.width, image_files.height))" - - switch resolution.Modifier { - case models.CriterionModifierEquals: - f.addHaving(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, min, max)) - case models.CriterionModifierNotEquals: - f.addHaving(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, min, max)) - case models.CriterionModifierLessThan: - f.addHaving(fmt.Sprintf("%s < %d", widthHeight, min)) - case models.CriterionModifierGreaterThan: - f.addHaving(fmt.Sprintf("%s > %d", widthHeight, max)) - } - } - } -} - var gallerySortOptions = sortOptions{ "created_at", "date", @@ -1194,92 +860,36 @@ func (qb *GalleryStore) GetURLs(ctx context.Context, galleryID int) ([]string, e return galleriesURLsTableMgr.get(ctx, galleryID) } -func (qb *GalleryStore) filesRepository() *filesRepository { - return &filesRepository{ - repository: repository{ - tx: qb.tx, - tableName: galleriesFilesTable, - idColumn: galleryIDColumn, - }, - } -} - func (qb *GalleryStore) AddFileID(ctx context.Context, id int, fileID models.FileID) error { const firstPrimary = false return galleriesFilesTableMgr.insertJoins(ctx, id, firstPrimary, []models.FileID{fileID}) } -func (qb *GalleryStore) performersRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: performersGalleriesTable, - idColumn: galleryIDColumn, - }, - fkColumn: "performer_id", - } -} - func (qb *GalleryStore) GetPerformerIDs(ctx context.Context, id int) ([]int, error) { - return qb.performersRepository().getIDs(ctx, id) -} - -func (qb *GalleryStore) tagsRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: galleriesTagsTable, - idColumn: galleryIDColumn, - }, - fkColumn: "tag_id", - foreignTable: tagTable, - orderBy: "tags.name ASC", - } + return galleryRepository.performers.getIDs(ctx, id) } func (qb *GalleryStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { - return qb.tagsRepository().getIDs(ctx, id) -} - -func (qb *GalleryStore) imagesRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: galleriesImagesTable, - idColumn: galleryIDColumn, - }, - fkColumn: "image_id", - } + return galleryRepository.tags.getIDs(ctx, id) } func (qb *GalleryStore) GetImageIDs(ctx context.Context, galleryID int) ([]int, error) { - return qb.imagesRepository().getIDs(ctx, galleryID) + return galleryRepository.images.getIDs(ctx, galleryID) } func (qb *GalleryStore) AddImages(ctx context.Context, galleryID int, imageIDs ...int) error { - return qb.imagesRepository().insertOrIgnore(ctx, galleryID, imageIDs...) + return galleryRepository.images.insertOrIgnore(ctx, galleryID, imageIDs...) } func (qb *GalleryStore) RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error { - return qb.imagesRepository().destroyJoins(ctx, galleryID, imageIDs...) + return galleryRepository.images.destroyJoins(ctx, galleryID, imageIDs...) } func (qb *GalleryStore) UpdateImages(ctx context.Context, galleryID int, imageIDs []int) error { // Delete the existing joins and then create new ones - return qb.imagesRepository().replace(ctx, galleryID, imageIDs) -} - -func (qb *GalleryStore) scenesRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: galleriesScenesTable, - idColumn: galleryIDColumn, - }, - fkColumn: sceneIDColumn, - } + return galleryRepository.images.replace(ctx, galleryID, imageIDs) } func (qb *GalleryStore) GetSceneIDs(ctx context.Context, id int) ([]int, error) { - return qb.scenesRepository().getIDs(ctx, id) + return galleryRepository.scenes.getIDs(ctx, id) } diff --git a/pkg/sqlite/gallery_filter.go b/pkg/sqlite/gallery_filter.go new file mode 100644 index 000000000..abca78b10 --- /dev/null +++ b/pkg/sqlite/gallery_filter.go @@ -0,0 +1,432 @@ +package sqlite + +import ( + "context" + "fmt" + "path/filepath" + "regexp" + + "github.com/stashapp/stash/pkg/models" +) + +type galleryFilterHandler struct { + galleryFilter *models.GalleryFilterType +} + +func (qb *galleryFilterHandler) validate() error { + galleryFilter := qb.galleryFilter + if galleryFilter == nil { + return nil + } + + if err := validateFilterCombination(galleryFilter.OperatorFilter); err != nil { + return err + } + + if subFilter := galleryFilter.SubFilter(); subFilter != nil { + sqb := &galleryFilterHandler{galleryFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + return nil +} + +func (qb *galleryFilterHandler) handle(ctx context.Context, f *filterBuilder) { + galleryFilter := qb.galleryFilter + if galleryFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := galleryFilter.SubFilter() + if sf != nil { + sub := &galleryFilterHandler{sf} + handleSubFilter(ctx, sub, f, galleryFilter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *galleryFilterHandler) criterionHandler() criterionHandler { + filter := qb.galleryFilter + return compoundHandler{ + intCriterionHandler(filter.ID, "galleries.id", nil), + stringCriterionHandler(filter.Title, "galleries.title"), + stringCriterionHandler(filter.Code, "galleries.code"), + stringCriterionHandler(filter.Details, "galleries.details"), + stringCriterionHandler(filter.Photographer, "galleries.photographer"), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if filter.Checksum != nil { + galleryRepository.addGalleriesFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_md5", "galleries_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") + } + + stringCriterionHandler(filter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) + }), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if filter.IsZip != nil { + galleryRepository.addGalleriesFilesTable(f) + if *filter.IsZip { + + f.addWhere("galleries_files.file_id IS NOT NULL") + } else { + f.addWhere("galleries_files.file_id IS NULL") + } + } + }), + + qb.pathCriterionHandler(filter.Path), + qb.fileCountCriterionHandler(filter.FileCount), + intCriterionHandler(filter.Rating100, "galleries.rating", nil), + qb.urlsCriterionHandler(filter.URL), + boolCriterionHandler(filter.Organized, "galleries.organized", nil), + qb.missingCriterionHandler(filter.IsMissing), + qb.tagsCriterionHandler(filter.Tags), + qb.tagCountCriterionHandler(filter.TagCount), + qb.performersCriterionHandler(filter.Performers), + qb.performerCountCriterionHandler(filter.PerformerCount), + qb.scenesCriterionHandler(filter.Scenes), + qb.hasChaptersCriterionHandler(filter.HasChapters), + studioCriterionHandler(galleryTable, filter.Studios), + qb.performerTagsCriterionHandler(filter.PerformerTags), + qb.averageResolutionCriterionHandler(filter.AverageResolution), + qb.imageCountCriterionHandler(filter.ImageCount), + qb.performerFavoriteCriterionHandler(filter.PerformerFavorite), + qb.performerAgeCriterionHandler(filter.PerformerAge), + &dateCriterionHandler{filter.Date, "galleries.date", nil}, + ×tampCriterionHandler{filter.CreatedAt, "galleries.created_at", nil}, + ×tampCriterionHandler{filter.UpdatedAt, "galleries.updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "scenes_galleries.scene_id", + relatedRepo: sceneRepository.repository, + relatedHandler: &sceneFilterHandler{filter.ScenesFilter}, + joinFn: func(f *filterBuilder) { + galleryRepository.scenes.innerJoin(f, "", "galleries.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "galleries_images.image_id", + relatedRepo: imageRepository.repository, + relatedHandler: &imageFilterHandler{filter.ImagesFilter}, + joinFn: func(f *filterBuilder) { + galleryRepository.images.innerJoin(f, "", "galleries.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "performers_join.performer_id", + relatedRepo: performerRepository.repository, + relatedHandler: &performerFilterHandler{filter.PerformersFilter}, + joinFn: func(f *filterBuilder) { + galleryRepository.performers.innerJoin(f, "performers_join", "galleries.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "galleries.studio_id", + relatedRepo: studioRepository.repository, + relatedHandler: &studioFilterHandler{filter.StudiosFilter}, + }, + + &relatedFilterHandler{ + relatedIDCol: "gallery_tag.tag_id", + relatedRepo: tagRepository.repository, + relatedHandler: &tagFilterHandler{filter.TagsFilter}, + joinFn: func(f *filterBuilder) { + galleryRepository.tags.innerJoin(f, "gallery_tag", "galleries.id") + }, + }, + } +} + +func (qb *galleryFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: galleriesURLsTable, + stringColumn: galleriesURLColumn, + addJoinTable: func(f *filterBuilder) { + galleriesURLsTableMgr.join(f, "", "galleries.id") + }, + } + + return h.handler(url) +} + +func (qb *galleryFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { + return multiCriterionHandlerBuilder{ + primaryTable: galleryTable, + foreignTable: foreignTable, + joinTable: joinTable, + primaryFK: galleryIDColumn, + foreignFK: foreignFK, + addJoinsFunc: addJoinsFunc, + } +} + +func (qb *galleryFilterHandler) pathCriterionHandler(c *models.StringCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + galleryRepository.addFoldersTable(f) + f.addLeftJoin(folderTable, "gallery_folder", "galleries.folder_id = gallery_folder.id") + + const pathColumn = "folders.path" + const basenameColumn = "files.basename" + const folderPathColumn = "gallery_folder.path" + + addWildcards := true + not := false + + if modifier := c.Modifier; c.Modifier.IsValid() { + switch modifier { + case models.CriterionModifierIncludes: + clause := getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not) + clause2 := getStringSearchClause([]string{folderPathColumn}, c.Value, false) + f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) + case models.CriterionModifierExcludes: + not = true + clause := getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not) + clause2 := getStringSearchClause([]string{folderPathColumn}, c.Value, true) + f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) + case models.CriterionModifierEquals: + addWildcards = false + clause := getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not) + clause2 := makeClause(folderPathColumn+" LIKE ?", c.Value) + f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) + case models.CriterionModifierNotEquals: + addWildcards = false + not = true + clause := getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not) + clause2 := makeClause(folderPathColumn+" NOT LIKE ?", c.Value) + f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) + case models.CriterionModifierMatchesRegex: + if _, err := regexp.Compile(c.Value); err != nil { + f.setError(err) + return + } + filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) + clause := makeClause(fmt.Sprintf("%s IS NOT NULL AND %s IS NOT NULL AND %s regexp ?", pathColumn, basenameColumn, filepathColumn), c.Value) + clause2 := makeClause(fmt.Sprintf("%s IS NOT NULL AND %[1]s regexp ?", folderPathColumn), c.Value) + f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) + case models.CriterionModifierNotMatchesRegex: + if _, err := regexp.Compile(c.Value); err != nil { + f.setError(err) + return + } + filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) + f.addWhere(fmt.Sprintf("%s IS NULL OR %s IS NULL OR %s NOT regexp ?", pathColumn, basenameColumn, filepathColumn), c.Value) + f.addWhere(fmt.Sprintf("%s IS NULL OR %[1]s NOT regexp ?", folderPathColumn), c.Value) + case models.CriterionModifierIsNull: + f.addWhere(fmt.Sprintf("%s IS NULL OR TRIM(%[1]s) = '' OR %s IS NULL OR TRIM(%[2]s) = ''", pathColumn, basenameColumn)) + f.addWhere(fmt.Sprintf("%s IS NULL OR TRIM(%[1]s) = ''", folderPathColumn)) + case models.CriterionModifierNotNull: + clause := makeClause(fmt.Sprintf("%s IS NOT NULL AND TRIM(%[1]s) != '' AND %s IS NOT NULL AND TRIM(%[2]s) != ''", pathColumn, basenameColumn)) + clause2 := makeClause(fmt.Sprintf("%s IS NOT NULL AND TRIM(%[1]s) != ''", folderPathColumn)) + f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) + default: + panic("unsupported string filter modifier") + } + } + } + } +} + +func (qb *galleryFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: galleriesFilesTable, + primaryFK: galleryIDColumn, + } + + return h.handler(fileCount) +} + +func (qb *galleryFilterHandler) missingCriterionHandler(isMissing *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "url": + galleriesURLsTableMgr.join(f, "", "galleries.id") + f.addWhere("gallery_urls.url IS NULL") + case "scenes": + f.addLeftJoin("scenes_galleries", "scenes_join", "scenes_join.gallery_id = galleries.id") + f.addWhere("scenes_join.gallery_id IS NULL") + case "studio": + f.addWhere("galleries.studio_id IS NULL") + case "performers": + galleryRepository.performers.join(f, "performers_join", "galleries.id") + f.addWhere("performers_join.gallery_id IS NULL") + case "date": + f.addWhere("galleries.date IS NULL OR galleries.date IS \"\"") + case "tags": + galleryRepository.tags.join(f, "tags_join", "galleries.id") + f.addWhere("tags_join.gallery_id IS NULL") + default: + f.addWhere("(galleries." + *isMissing + " IS NULL OR TRIM(galleries." + *isMissing + ") = '')") + } + } + } +} + +func (qb *galleryFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: galleryTable, + foreignTable: tagTable, + foreignFK: "tag_id", + + relationsTable: "tags_relations", + joinAs: "gallery_tag", + joinTable: galleriesTagsTable, + primaryFK: galleryIDColumn, + } + + return h.handler(tags) +} + +func (qb *galleryFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: galleriesTagsTable, + primaryFK: galleryIDColumn, + } + + return h.handler(tagCount) +} + +func (qb *galleryFilterHandler) scenesCriterionHandler(scenes *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + galleryRepository.scenes.join(f, "", "galleries.id") + f.addLeftJoin("scenes", "", "scenes_galleries.scene_id = scenes.id") + } + h := qb.getMultiCriterionHandlerBuilder(sceneTable, galleriesScenesTable, "scene_id", addJoinsFunc) + return h.handler(scenes) +} + +func (qb *galleryFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { + h := joinedMultiCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: performersGalleriesTable, + joinAs: "performers_join", + primaryFK: galleryIDColumn, + foreignFK: performerIDColumn, + + addJoinTable: func(f *filterBuilder) { + galleryRepository.performers.join(f, "performers_join", "galleries.id") + }, + } + + return h.handler(performers) +} + +func (qb *galleryFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: performersGalleriesTable, + primaryFK: galleryIDColumn, + } + + return h.handler(performerCount) +} + +func (qb *galleryFilterHandler) imageCountCriterionHandler(imageCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: galleriesImagesTable, + primaryFK: galleryIDColumn, + } + + return h.handler(imageCount) +} + +func (qb *galleryFilterHandler) hasChaptersCriterionHandler(hasChapters *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if hasChapters != nil { + f.addLeftJoin("galleries_chapters", "", "galleries_chapters.gallery_id = galleries.id") + if *hasChapters == "true" { + f.addHaving("count(galleries_chapters.gallery_id) > 0") + } else { + f.addWhere("galleries_chapters.id IS NULL") + } + } + } +} + +func (qb *galleryFilterHandler) performerTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandler { + return &joinedPerformerTagsHandler{ + criterion: tags, + primaryTable: galleryTable, + joinTable: performersGalleriesTable, + joinPrimaryKey: galleryIDColumn, + } +} + +func (qb *galleryFilterHandler) performerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerfavorite != nil { + f.addLeftJoin("performers_galleries", "", "galleries.id = performers_galleries.gallery_id") + + if *performerfavorite { + // contains at least one favorite + f.addLeftJoin("performers", "", "performers.id = performers_galleries.performer_id") + f.addWhere("performers.favorite = 1") + } else { + // contains zero favorites + f.addLeftJoin(`(SELECT performers_galleries.gallery_id as id FROM performers_galleries +JOIN performers ON performers.id = performers_galleries.performer_id +GROUP BY performers_galleries.gallery_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "galleries.id = nofaves.id") + f.addWhere("performers_galleries.gallery_id IS NULL OR nofaves.id IS NOT NULL") + } + } + } +} + +func (qb *galleryFilterHandler) performerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerAge != nil { + f.addInnerJoin("performers_galleries", "", "galleries.id = performers_galleries.gallery_id") + f.addInnerJoin("performers", "", "performers_galleries.performer_id = performers.id") + + f.addWhere("galleries.date != '' AND performers.birthdate != ''") + f.addWhere("galleries.date IS NOT NULL AND performers.birthdate IS NOT NULL") + + ageCalc := "cast(strftime('%Y.%m%d', galleries.date) - strftime('%Y.%m%d', performers.birthdate) as int)" + whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) + f.addWhere(whereClause, args...) + } + } +} + +func (qb *galleryFilterHandler) averageResolutionCriterionHandler(resolution *models.ResolutionCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if resolution != nil && resolution.Value.IsValid() { + galleryRepository.images.join(f, "images_join", "galleries.id") + f.addLeftJoin("images", "", "images_join.image_id = images.id") + f.addLeftJoin("images_files", "", "images.id = images_files.image_id") + f.addLeftJoin("image_files", "", "images_files.file_id = image_files.file_id") + + min := resolution.Value.GetMinResolution() + max := resolution.Value.GetMaxResolution() + + const widthHeight = "avg(MIN(image_files.width, image_files.height))" + + switch resolution.Modifier { + case models.CriterionModifierEquals: + f.addHaving(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, min, max)) + case models.CriterionModifierNotEquals: + f.addHaving(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, min, max)) + case models.CriterionModifierLessThan: + f.addHaving(fmt.Sprintf("%s < %d", widthHeight, min)) + case models.CriterionModifierGreaterThan: + f.addHaving(fmt.Sprintf("%s > %d", widthHeight, max)) + } + } + } +} diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index c57ba08b8..08908220b 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -1534,10 +1534,12 @@ func TestGalleryQueryPathOr(t *testing.T) { Value: gallery1Path, Modifier: models.CriterionModifierEquals, }, - Or: &models.GalleryFilterType{ - Path: &models.StringCriterionInput{ - Value: gallery2Path, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.GalleryFilterType]{ + Or: &models.GalleryFilterType{ + Path: &models.StringCriterionInput{ + Value: gallery2Path, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -1568,10 +1570,12 @@ func TestGalleryQueryPathAndRating(t *testing.T) { Value: galleryPath, Modifier: models.CriterionModifierEquals, }, - And: &models.GalleryFilterType{ - Rating100: &models.IntCriterionInput{ - Value: *galleryRating, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.GalleryFilterType]{ + And: &models.GalleryFilterType{ + Rating100: &models.IntCriterionInput{ + Value: *galleryRating, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -1609,8 +1613,10 @@ func TestGalleryQueryPathNotRating(t *testing.T) { galleryFilter := models.GalleryFilterType{ Path: &pathCriterion, - Not: &models.GalleryFilterType{ - Rating100: &ratingCriterion, + OperatorFilter: models.OperatorFilter[models.GalleryFilterType]{ + Not: &models.GalleryFilterType{ + Rating100: &ratingCriterion, + }, }, } @@ -1641,8 +1647,10 @@ func TestGalleryIllegalQuery(t *testing.T) { } galleryFilter := &models.GalleryFilterType{ - And: &subFilter, - Or: &subFilter, + OperatorFilter: models.OperatorFilter[models.GalleryFilterType]{ + And: &subFilter, + Or: &subFilter, + }, } withTxn(func(ctx context.Context) error { diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 02cd09ec7..dc4ed920f 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -112,24 +112,87 @@ func (r *imageRowRecord) fromPartial(i models.ImagePartial) { r.setTimestamp("updated_at", i.UpdatedAt) } -type ImageStore struct { +type imageRepositoryType struct { repository - - tableMgr *table - oCounterManager - - fileStore *FileStore + performers joinRepository + galleries joinRepository + tags joinRepository + files filesRepository } -func NewImageStore(fileStore *FileStore) *ImageStore { - return &ImageStore{ +func (r *imageRepositoryType) addImagesFilesTable(f *filterBuilder) { + f.addLeftJoin(imagesFilesTable, "", "images_files.image_id = images.id") +} + +func (r *imageRepositoryType) addFilesTable(f *filterBuilder) { + r.addImagesFilesTable(f) + f.addLeftJoin(fileTable, "", "images_files.file_id = files.id") +} + +func (r *imageRepositoryType) addFoldersTable(f *filterBuilder) { + r.addFilesTable(f) + f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") +} + +func (r *imageRepositoryType) addImageFilesTable(f *filterBuilder) { + r.addImagesFilesTable(f) + f.addLeftJoin(imageFileTable, "", "image_files.file_id = images_files.file_id") +} + +var ( + imageRepository = imageRepositoryType{ repository: repository{ tableName: imageTable, idColumn: idColumn, }, + + performers: joinRepository{ + repository: repository{ + tableName: performersImagesTable, + idColumn: imageIDColumn, + }, + fkColumn: performerIDColumn, + }, + + galleries: joinRepository{ + repository: repository{ + tableName: galleriesImagesTable, + idColumn: imageIDColumn, + }, + fkColumn: galleryIDColumn, + }, + + files: filesRepository{ + repository: repository{ + tableName: imagesFilesTable, + idColumn: imageIDColumn, + }, + }, + + tags: joinRepository{ + repository: repository{ + tableName: imagesTagsTable, + idColumn: imageIDColumn, + }, + fkColumn: tagIDColumn, + foreignTable: tagTable, + orderBy: "tags.name ASC", + }, + } +) + +type ImageStore struct { + tableMgr *table + oCounterManager + + repo *storeRepository +} + +func NewImageStore(r *storeRepository) *ImageStore { + return &ImageStore{ tableMgr: imageTableMgr, oCounterManager: oCounterManager{imageTableMgr}, - fileStore: fileStore, + repo: r, } } @@ -418,13 +481,13 @@ func (qb *ImageStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*mo } func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]models.File, error) { - fileIDs, err := qb.filesRepository().get(ctx, id) + fileIDs, err := imageRepository.files.get(ctx, id) if err != nil { return nil, err } // use fileStore to load files - files, err := qb.fileStore.Find(ctx, fileIDs...) + files, err := qb.repo.File.Find(ctx, fileIDs...) if err != nil { return nil, err } @@ -434,7 +497,7 @@ func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]models.File, erro func (qb *ImageStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) { const primaryOnly = false - return qb.filesRepository().getMany(ctx, ids, primaryOnly) + return imageRepository.files.getMany(ctx, ids, primaryOnly) } func (qb *ImageStore) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Image, error) { @@ -642,110 +705,6 @@ func (qb *ImageStore) All(ctx context.Context) ([]*models.Image, error) { return qb.getMany(ctx, qb.selectDataset()) } -func (qb *ImageStore) validateFilter(imageFilter *models.ImageFilterType) error { - const and = "AND" - const or = "OR" - const not = "NOT" - - if imageFilter.And != nil { - if imageFilter.Or != nil { - return illegalFilterCombination(and, or) - } - if imageFilter.Not != nil { - return illegalFilterCombination(and, not) - } - - return qb.validateFilter(imageFilter.And) - } - - if imageFilter.Or != nil { - if imageFilter.Not != nil { - return illegalFilterCombination(or, not) - } - - return qb.validateFilter(imageFilter.Or) - } - - if imageFilter.Not != nil { - return qb.validateFilter(imageFilter.Not) - } - - return nil -} - -func (qb *ImageStore) makeFilter(ctx context.Context, imageFilter *models.ImageFilterType) *filterBuilder { - query := &filterBuilder{} - - if imageFilter.And != nil { - query.and(qb.makeFilter(ctx, imageFilter.And)) - } - if imageFilter.Or != nil { - query.or(qb.makeFilter(ctx, imageFilter.Or)) - } - if imageFilter.Not != nil { - query.not(qb.makeFilter(ctx, imageFilter.Not)) - } - - query.handleCriterion(ctx, intCriterionHandler(imageFilter.ID, "images.id", nil)) - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if imageFilter.Checksum != nil { - qb.addImagesFilesTable(f) - f.addInnerJoin(fingerprintTable, "fingerprints_md5", "images_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") - } - - stringCriterionHandler(imageFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) - })) - query.handleCriterion(ctx, stringCriterionHandler(imageFilter.Title, "images.title")) - query.handleCriterion(ctx, stringCriterionHandler(imageFilter.Code, "images.code")) - query.handleCriterion(ctx, stringCriterionHandler(imageFilter.Details, "images.details")) - query.handleCriterion(ctx, stringCriterionHandler(imageFilter.Photographer, "images.photographer")) - - query.handleCriterion(ctx, pathCriterionHandler(imageFilter.Path, "folders.path", "files.basename", qb.addFoldersTable)) - query.handleCriterion(ctx, imageFileCountCriterionHandler(qb, imageFilter.FileCount)) - query.handleCriterion(ctx, intCriterionHandler(imageFilter.Rating100, "images.rating", nil)) - query.handleCriterion(ctx, intCriterionHandler(imageFilter.OCounter, "images.o_counter", nil)) - query.handleCriterion(ctx, boolCriterionHandler(imageFilter.Organized, "images.organized", nil)) - query.handleCriterion(ctx, dateCriterionHandler(imageFilter.Date, "images.date")) - query.handleCriterion(ctx, imageURLsCriterionHandler(imageFilter.URL)) - - query.handleCriterion(ctx, resolutionCriterionHandler(imageFilter.Resolution, "image_files.height", "image_files.width", qb.addImageFilesTable)) - query.handleCriterion(ctx, orientationCriterionHandler(imageFilter.Orientation, "image_files.height", "image_files.width", qb.addImageFilesTable)) - query.handleCriterion(ctx, imageIsMissingCriterionHandler(qb, imageFilter.IsMissing)) - - query.handleCriterion(ctx, imageTagsCriterionHandler(qb, imageFilter.Tags)) - query.handleCriterion(ctx, imageTagCountCriterionHandler(qb, imageFilter.TagCount)) - query.handleCriterion(ctx, imageGalleriesCriterionHandler(qb, imageFilter.Galleries)) - query.handleCriterion(ctx, imagePerformersCriterionHandler(qb, imageFilter.Performers)) - query.handleCriterion(ctx, imagePerformerCountCriterionHandler(qb, imageFilter.PerformerCount)) - query.handleCriterion(ctx, studioCriterionHandler(imageTable, imageFilter.Studios)) - query.handleCriterion(ctx, imagePerformerTagsCriterionHandler(qb, imageFilter.PerformerTags)) - query.handleCriterion(ctx, imagePerformerFavoriteCriterionHandler(imageFilter.PerformerFavorite)) - query.handleCriterion(ctx, imagePerformerAgeCriterionHandler(imageFilter.PerformerAge)) - query.handleCriterion(ctx, timestampCriterionHandler(imageFilter.CreatedAt, "images.created_at")) - query.handleCriterion(ctx, timestampCriterionHandler(imageFilter.UpdatedAt, "images.updated_at")) - - return query -} - -func (qb *ImageStore) addImagesFilesTable(f *filterBuilder) { - f.addLeftJoin(imagesFilesTable, "", "images_files.image_id = images.id") -} - -func (qb *ImageStore) addFilesTable(f *filterBuilder) { - qb.addImagesFilesTable(f) - f.addLeftJoin(fileTable, "", "images_files.file_id = files.id") -} - -func (qb *ImageStore) addFoldersTable(f *filterBuilder) { - qb.addFilesTable(f) - f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") -} - -func (qb *ImageStore) addImageFilesTable(f *filterBuilder) { - qb.addImagesFilesTable(f) - f.addLeftJoin(imageFileTable, "", "image_files.file_id = images_files.file_id") -} - func (qb *ImageStore) makeQuery(ctx context.Context, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if imageFilter == nil { imageFilter = &models.ImageFilterType{} @@ -754,7 +713,7 @@ func (qb *ImageStore) makeQuery(ctx context.Context, imageFilter *models.ImageFi findFilter = &models.FindFilterType{} } - query := qb.newQuery() + query := imageRepository.newQuery() distinctIDs(&query, imageTable) if q := findFilter.Q; q != nil && *q != "" { @@ -782,10 +741,9 @@ func (qb *ImageStore) makeQuery(ctx context.Context, imageFilter *models.ImageFi query.parseQueryString(searchColumns, *q) } - if err := qb.validateFilter(imageFilter); err != nil { - return nil, err - } - filter := qb.makeFilter(ctx, imageFilter) + filter := filterBuilderFromHandler(ctx, &imageFilterHandler{ + imageFilter: imageFilter, + }) if err := query.addFilter(filter); err != nil { return nil, err @@ -824,7 +782,7 @@ func (qb *ImageStore) queryGroupedFields(ctx context.Context, options models.Ima return models.NewImageQueryResult(qb), nil } - aggregateQuery := qb.newQuery() + aggregateQuery := imageRepository.newQuery() if options.Count { aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total") @@ -868,7 +826,7 @@ func (qb *ImageStore) queryGroupedFields(ctx context.Context, options models.Ima Megapixels null.Float Size null.Float }{} - if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { + if err := imageRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { return nil, err } @@ -888,171 +846,6 @@ func (qb *ImageStore) QueryCount(ctx context.Context, imageFilter *models.ImageF return query.executeCount(ctx) } -func imageFileCountCriterionHandler(qb *ImageStore, fileCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: imageTable, - joinTable: imagesFilesTable, - primaryFK: imageIDColumn, - } - - return h.handler(fileCount) -} - -func imageIsMissingCriterionHandler(qb *ImageStore, isMissing *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if isMissing != nil && *isMissing != "" { - switch *isMissing { - case "studio": - f.addWhere("images.studio_id IS NULL") - case "performers": - qb.performersRepository().join(f, "performers_join", "images.id") - f.addWhere("performers_join.image_id IS NULL") - case "galleries": - qb.galleriesRepository().join(f, "galleries_join", "images.id") - f.addWhere("galleries_join.image_id IS NULL") - case "tags": - qb.tagsRepository().join(f, "tags_join", "images.id") - f.addWhere("tags_join.image_id IS NULL") - default: - f.addWhere("(images." + *isMissing + " IS NULL OR TRIM(images." + *isMissing + ") = '')") - } - } - } -} - -func imageURLsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { - h := stringListCriterionHandlerBuilder{ - joinTable: imagesURLsTable, - stringColumn: imageURLColumn, - addJoinTable: func(f *filterBuilder) { - imagesURLsTableMgr.join(f, "", "images.id") - }, - } - - return h.handler(url) -} - -func (qb *ImageStore) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { - return multiCriterionHandlerBuilder{ - primaryTable: imageTable, - foreignTable: foreignTable, - joinTable: joinTable, - primaryFK: imageIDColumn, - foreignFK: foreignFK, - addJoinsFunc: addJoinsFunc, - } -} - -func imageTagsCriterionHandler(qb *ImageStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := joinedHierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: imageTable, - foreignTable: tagTable, - foreignFK: "tag_id", - - relationsTable: "tags_relations", - joinAs: "image_tag", - joinTable: imagesTagsTable, - primaryFK: imageIDColumn, - } - - return h.handler(tags) -} - -func imageTagCountCriterionHandler(qb *ImageStore, tagCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: imageTable, - joinTable: imagesTagsTable, - primaryFK: imageIDColumn, - } - - return h.handler(tagCount) -} - -func imageGalleriesCriterionHandler(qb *ImageStore, galleries *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - if galleries.Modifier == models.CriterionModifierIncludes || galleries.Modifier == models.CriterionModifierIncludesAll { - f.addInnerJoin(galleriesImagesTable, "", "galleries_images.image_id = images.id") - f.addInnerJoin(galleryTable, "", "galleries_images.gallery_id = galleries.id") - } - } - h := qb.getMultiCriterionHandlerBuilder(galleryTable, galleriesImagesTable, galleryIDColumn, addJoinsFunc) - - return h.handler(galleries) -} - -func imagePerformersCriterionHandler(qb *ImageStore, performers *models.MultiCriterionInput) criterionHandlerFunc { - h := joinedMultiCriterionHandlerBuilder{ - primaryTable: imageTable, - joinTable: performersImagesTable, - joinAs: "performers_join", - primaryFK: imageIDColumn, - foreignFK: performerIDColumn, - - addJoinTable: func(f *filterBuilder) { - qb.performersRepository().join(f, "performers_join", "images.id") - }, - } - - return h.handler(performers) -} - -func imagePerformerCountCriterionHandler(qb *ImageStore, performerCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: imageTable, - joinTable: performersImagesTable, - primaryFK: imageIDColumn, - } - - return h.handler(performerCount) -} - -func imagePerformerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performerfavorite != nil { - f.addLeftJoin("performers_images", "", "images.id = performers_images.image_id") - - if *performerfavorite { - // contains at least one favorite - f.addLeftJoin("performers", "", "performers.id = performers_images.performer_id") - f.addWhere("performers.favorite = 1") - } else { - // contains zero favorites - f.addLeftJoin(`(SELECT performers_images.image_id as id FROM performers_images -JOIN performers ON performers.id = performers_images.performer_id -GROUP BY performers_images.image_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "images.id = nofaves.id") - f.addWhere("performers_images.image_id IS NULL OR nofaves.id IS NOT NULL") - } - } - } -} - -func imagePerformerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performerAge != nil { - f.addInnerJoin("performers_images", "", "images.id = performers_images.image_id") - f.addInnerJoin("performers", "", "performers_images.performer_id = performers.id") - - f.addWhere("images.date != '' AND performers.birthdate != ''") - f.addWhere("images.date IS NOT NULL AND performers.birthdate IS NOT NULL") - - ageCalc := "cast(strftime('%Y.%m%d', images.date) - strftime('%Y.%m%d', performers.birthdate) as int)" - whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) - f.addWhere(whereClause, args...) - } - } -} - -func imagePerformerTagsCriterionHandler(qb *ImageStore, tags *models.HierarchicalMultiCriterionInput) criterionHandler { - return &joinedPerformerTagsHandler{ - criterion: tags, - primaryTable: imageTable, - joinTable: performersImagesTable, - joinPrimaryKey: imageIDColumn, - } -} - var imageSortOptions = sortOptions{ "created_at", "date", @@ -1138,34 +931,13 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod return nil } -func (qb *ImageStore) galleriesRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: galleriesImagesTable, - idColumn: imageIDColumn, - }, - fkColumn: galleryIDColumn, - } -} - -func (qb *ImageStore) filesRepository() *filesRepository { - return &filesRepository{ - repository: repository{ - tx: qb.tx, - tableName: imagesFilesTable, - idColumn: imageIDColumn, - }, - } -} - func (qb *ImageStore) AddFileID(ctx context.Context, id int, fileID models.FileID) error { const firstPrimary = false return imagesFilesTableMgr.insertJoins(ctx, id, firstPrimary, []models.FileID{fileID}) } func (qb *ImageStore) GetGalleryIDs(ctx context.Context, imageID int) ([]int, error) { - return qb.galleriesRepository().getIDs(ctx, imageID) + return imageRepository.galleries.getIDs(ctx, imageID) } // func (qb *imageQueryBuilder) UpdateGalleries(ctx context.Context, imageID int, galleryIDs []int) error { @@ -1173,46 +945,22 @@ func (qb *ImageStore) GetGalleryIDs(ctx context.Context, imageID int) ([]int, er // return qb.galleriesRepository().replace(ctx, imageID, galleryIDs) // } -func (qb *ImageStore) performersRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: performersImagesTable, - idColumn: imageIDColumn, - }, - fkColumn: performerIDColumn, - } -} - func (qb *ImageStore) GetPerformerIDs(ctx context.Context, imageID int) ([]int, error) { - return qb.performersRepository().getIDs(ctx, imageID) + return imageRepository.performers.getIDs(ctx, imageID) } func (qb *ImageStore) UpdatePerformers(ctx context.Context, imageID int, performerIDs []int) error { // Delete the existing joins and then create new ones - return qb.performersRepository().replace(ctx, imageID, performerIDs) -} - -func (qb *ImageStore) tagsRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: imagesTagsTable, - idColumn: imageIDColumn, - }, - fkColumn: tagIDColumn, - foreignTable: tagTable, - orderBy: "tags.name ASC", - } + return imageRepository.performers.replace(ctx, imageID, performerIDs) } func (qb *ImageStore) GetTagIDs(ctx context.Context, imageID int) ([]int, error) { - return qb.tagsRepository().getIDs(ctx, imageID) + return imageRepository.tags.getIDs(ctx, imageID) } func (qb *ImageStore) UpdateTags(ctx context.Context, imageID int, tagIDs []int) error { // Delete the existing joins and then create new ones - return qb.tagsRepository().replace(ctx, imageID, tagIDs) + return imageRepository.tags.replace(ctx, imageID, tagIDs) } func (qb *ImageStore) GetURLs(ctx context.Context, imageID int) ([]string, error) { diff --git a/pkg/sqlite/image_filter.go b/pkg/sqlite/image_filter.go new file mode 100644 index 000000000..4fef48271 --- /dev/null +++ b/pkg/sqlite/image_filter.go @@ -0,0 +1,290 @@ +package sqlite + +import ( + "context" + + "github.com/stashapp/stash/pkg/models" +) + +type imageFilterHandler struct { + imageFilter *models.ImageFilterType +} + +func (qb *imageFilterHandler) validate() error { + imageFilter := qb.imageFilter + if imageFilter == nil { + return nil + } + + if err := validateFilterCombination(imageFilter.OperatorFilter); err != nil { + return err + } + + if subFilter := imageFilter.SubFilter(); subFilter != nil { + sqb := &imageFilterHandler{imageFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + return nil +} + +func (qb *imageFilterHandler) handle(ctx context.Context, f *filterBuilder) { + imageFilter := qb.imageFilter + if imageFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := imageFilter.SubFilter() + if sf != nil { + sub := &imageFilterHandler{sf} + handleSubFilter(ctx, sub, f, imageFilter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *imageFilterHandler) criterionHandler() criterionHandler { + imageFilter := qb.imageFilter + return compoundHandler{ + intCriterionHandler(imageFilter.ID, "images.id", nil), + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if imageFilter.Checksum != nil { + imageRepository.addImagesFilesTable(f) + f.addInnerJoin(fingerprintTable, "fingerprints_md5", "images_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") + } + + stringCriterionHandler(imageFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) + }), + stringCriterionHandler(imageFilter.Title, "images.title"), + stringCriterionHandler(imageFilter.Code, "images.code"), + stringCriterionHandler(imageFilter.Details, "images.details"), + stringCriterionHandler(imageFilter.Photographer, "images.photographer"), + + pathCriterionHandler(imageFilter.Path, "folders.path", "files.basename", imageRepository.addFoldersTable), + qb.fileCountCriterionHandler(imageFilter.FileCount), + intCriterionHandler(imageFilter.Rating100, "images.rating", nil), + intCriterionHandler(imageFilter.OCounter, "images.o_counter", nil), + boolCriterionHandler(imageFilter.Organized, "images.organized", nil), + &dateCriterionHandler{imageFilter.Date, "images.date", nil}, + qb.urlsCriterionHandler(imageFilter.URL), + + resolutionCriterionHandler(imageFilter.Resolution, "image_files.height", "image_files.width", imageRepository.addImageFilesTable), + orientationCriterionHandler(imageFilter.Orientation, "image_files.height", "image_files.width", imageRepository.addImageFilesTable), + qb.missingCriterionHandler(imageFilter.IsMissing), + + qb.tagsCriterionHandler(imageFilter.Tags), + qb.tagCountCriterionHandler(imageFilter.TagCount), + qb.galleriesCriterionHandler(imageFilter.Galleries), + qb.performersCriterionHandler(imageFilter.Performers), + qb.performerCountCriterionHandler(imageFilter.PerformerCount), + studioCriterionHandler(imageTable, imageFilter.Studios), + qb.performerTagsCriterionHandler(imageFilter.PerformerTags), + qb.performerFavoriteCriterionHandler(imageFilter.PerformerFavorite), + qb.performerAgeCriterionHandler(imageFilter.PerformerAge), + ×tampCriterionHandler{imageFilter.CreatedAt, "images.created_at", nil}, + ×tampCriterionHandler{imageFilter.UpdatedAt, "images.updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "galleries_images.gallery_id", + relatedRepo: galleryRepository.repository, + relatedHandler: &galleryFilterHandler{imageFilter.GalleriesFilter}, + joinFn: func(f *filterBuilder) { + imageRepository.galleries.innerJoin(f, "", "images.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "performers_join.performer_id", + relatedRepo: performerRepository.repository, + relatedHandler: &performerFilterHandler{imageFilter.PerformersFilter}, + joinFn: func(f *filterBuilder) { + imageRepository.performers.innerJoin(f, "performers_join", "images.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "images.studio_id", + relatedRepo: studioRepository.repository, + relatedHandler: &studioFilterHandler{imageFilter.StudiosFilter}, + }, + + &relatedFilterHandler{ + relatedIDCol: "image_tag.tag_id", + relatedRepo: tagRepository.repository, + relatedHandler: &tagFilterHandler{imageFilter.TagsFilter}, + joinFn: func(f *filterBuilder) { + imageRepository.tags.innerJoin(f, "image_tag", "images.id") + }, + }, + } +} + +func (qb *imageFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: imageTable, + joinTable: imagesFilesTable, + primaryFK: imageIDColumn, + } + + return h.handler(fileCount) +} + +func (qb *imageFilterHandler) missingCriterionHandler(isMissing *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "studio": + f.addWhere("images.studio_id IS NULL") + case "performers": + imageRepository.performers.join(f, "performers_join", "images.id") + f.addWhere("performers_join.image_id IS NULL") + case "galleries": + imageRepository.galleries.join(f, "galleries_join", "images.id") + f.addWhere("galleries_join.image_id IS NULL") + case "tags": + imageRepository.tags.join(f, "tags_join", "images.id") + f.addWhere("tags_join.image_id IS NULL") + default: + f.addWhere("(images." + *isMissing + " IS NULL OR TRIM(images." + *isMissing + ") = '')") + } + } + } +} + +func (qb *imageFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: imagesURLsTable, + stringColumn: imageURLColumn, + addJoinTable: func(f *filterBuilder) { + imagesURLsTableMgr.join(f, "", "images.id") + }, + } + + return h.handler(url) +} + +func (qb *imageFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { + return multiCriterionHandlerBuilder{ + primaryTable: imageTable, + foreignTable: foreignTable, + joinTable: joinTable, + primaryFK: imageIDColumn, + foreignFK: foreignFK, + addJoinsFunc: addJoinsFunc, + } +} + +func (qb *imageFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: imageTable, + foreignTable: tagTable, + foreignFK: "tag_id", + + relationsTable: "tags_relations", + joinAs: "image_tag", + joinTable: imagesTagsTable, + primaryFK: imageIDColumn, + } + + return h.handler(tags) +} + +func (qb *imageFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: imageTable, + joinTable: imagesTagsTable, + primaryFK: imageIDColumn, + } + + return h.handler(tagCount) +} + +func (qb *imageFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + if galleries.Modifier == models.CriterionModifierIncludes || galleries.Modifier == models.CriterionModifierIncludesAll { + f.addInnerJoin(galleriesImagesTable, "", "galleries_images.image_id = images.id") + f.addInnerJoin(galleryTable, "", "galleries_images.gallery_id = galleries.id") + } + } + h := qb.getMultiCriterionHandlerBuilder(galleryTable, galleriesImagesTable, galleryIDColumn, addJoinsFunc) + + return h.handler(galleries) +} + +func (qb *imageFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { + h := joinedMultiCriterionHandlerBuilder{ + primaryTable: imageTable, + joinTable: performersImagesTable, + joinAs: "performers_join", + primaryFK: imageIDColumn, + foreignFK: performerIDColumn, + + addJoinTable: func(f *filterBuilder) { + imageRepository.performers.join(f, "performers_join", "images.id") + }, + } + + return h.handler(performers) +} + +func (qb *imageFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: imageTable, + joinTable: performersImagesTable, + primaryFK: imageIDColumn, + } + + return h.handler(performerCount) +} + +func (qb *imageFilterHandler) performerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerfavorite != nil { + f.addLeftJoin("performers_images", "", "images.id = performers_images.image_id") + + if *performerfavorite { + // contains at least one favorite + f.addLeftJoin("performers", "", "performers.id = performers_images.performer_id") + f.addWhere("performers.favorite = 1") + } else { + // contains zero favorites + f.addLeftJoin(`(SELECT performers_images.image_id as id FROM performers_images +JOIN performers ON performers.id = performers_images.performer_id +GROUP BY performers_images.image_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "images.id = nofaves.id") + f.addWhere("performers_images.image_id IS NULL OR nofaves.id IS NOT NULL") + } + } + } +} + +func (qb *imageFilterHandler) performerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerAge != nil { + f.addInnerJoin("performers_images", "", "images.id = performers_images.image_id") + f.addInnerJoin("performers", "", "performers_images.performer_id = performers.id") + + f.addWhere("images.date != '' AND performers.birthdate != ''") + f.addWhere("images.date IS NOT NULL AND performers.birthdate IS NOT NULL") + + ageCalc := "cast(strftime('%Y.%m%d', images.date) - strftime('%Y.%m%d', performers.birthdate) as int)" + whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) + f.addWhere(whereClause, args...) + } + } +} + +func (qb *imageFilterHandler) performerTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandler { + return &joinedPerformerTagsHandler{ + criterion: tags, + primaryTable: imageTable, + joinTable: performersImagesTable, + joinPrimaryKey: imageIDColumn, + } +} diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 7a5b9ce1e..e1246ebbe 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -1668,10 +1668,12 @@ func TestImageQueryPathOr(t *testing.T) { Value: image1Path, Modifier: models.CriterionModifierEquals, }, - Or: &models.ImageFilterType{ - Path: &models.StringCriterionInput{ - Value: image2Path, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.ImageFilterType]{ + Or: &models.ImageFilterType{ + Path: &models.StringCriterionInput{ + Value: image2Path, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -1702,10 +1704,12 @@ func TestImageQueryPathAndRating(t *testing.T) { Value: imagePath, Modifier: models.CriterionModifierEquals, }, - And: &models.ImageFilterType{ - Rating100: &models.IntCriterionInput{ - Value: int(imageRating.Int64), - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.ImageFilterType]{ + And: &models.ImageFilterType{ + Rating100: &models.IntCriterionInput{ + Value: int(imageRating.Int64), + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -1743,8 +1747,10 @@ func TestImageQueryPathNotRating(t *testing.T) { imageFilter := models.ImageFilterType{ Path: &pathCriterion, - Not: &models.ImageFilterType{ - Rating100: &ratingCriterion, + OperatorFilter: models.OperatorFilter[models.ImageFilterType]{ + Not: &models.ImageFilterType{ + Rating100: &ratingCriterion, + }, }, } @@ -1775,8 +1781,10 @@ func TestImageIllegalQuery(t *testing.T) { } imageFilter := &models.ImageFilterType{ - And: &subFilter, - Or: &subFilter, + OperatorFilter: models.OperatorFilter[models.ImageFilterType]{ + And: &subFilter, + Or: &subFilter, + }, } withTxn(func(ctx context.Context) error { diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index 0d7c429d0..acbf036f2 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -96,8 +96,25 @@ func (r *movieRowRecord) fromPartial(o models.MoviePartial) { r.setTimestamp("updated_at", o.UpdatedAt) } -type MovieStore struct { +type movieRepositoryType struct { repository + scenes repository +} + +var ( + movieRepository = movieRepositoryType{ + repository: repository{ + tableName: movieTable, + idColumn: idColumn, + }, + scenes: repository{ + tableName: moviesScenesTable, + idColumn: movieIDColumn, + }, + } +) + +type MovieStore struct { blobJoinQueryBuilder tableMgr *table @@ -105,10 +122,6 @@ type MovieStore struct { func NewMovieStore(blobStore *BlobStore) *MovieStore { return &MovieStore{ - repository: repository{ - tableName: movieTable, - idColumn: idColumn, - }, blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, joinTable: movieTable, @@ -180,7 +193,7 @@ func (qb *MovieStore) Destroy(ctx context.Context, id int) error { return err } - return qb.destroyExisting(ctx, []int{id}) + return movieRepository.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found @@ -327,25 +340,6 @@ func (qb *MovieStore) All(ctx context.Context) ([]*models.Movie, error) { )) } -func (qb *MovieStore) makeFilter(ctx context.Context, movieFilter *models.MovieFilterType) *filterBuilder { - query := &filterBuilder{} - - query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Name, "movies.name")) - query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Director, "movies.director")) - query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Synopsis, "movies.synopsis")) - query.handleCriterion(ctx, intCriterionHandler(movieFilter.Rating100, "movies.rating", nil)) - query.handleCriterion(ctx, floatIntCriterionHandler(movieFilter.Duration, "movies.duration", nil)) - query.handleCriterion(ctx, movieIsMissingCriterionHandler(qb, movieFilter.IsMissing)) - query.handleCriterion(ctx, stringCriterionHandler(movieFilter.URL, "movies.url")) - query.handleCriterion(ctx, studioCriterionHandler(movieTable, movieFilter.Studios)) - query.handleCriterion(ctx, moviePerformersCriterionHandler(qb, movieFilter.Performers)) - query.handleCriterion(ctx, dateCriterionHandler(movieFilter.Date, "movies.date")) - query.handleCriterion(ctx, timestampCriterionHandler(movieFilter.CreatedAt, "movies.created_at")) - query.handleCriterion(ctx, timestampCriterionHandler(movieFilter.UpdatedAt, "movies.updated_at")) - - return query -} - func (qb *MovieStore) makeQuery(ctx context.Context, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if findFilter == nil { findFilter = &models.FindFilterType{} @@ -354,7 +348,7 @@ func (qb *MovieStore) makeQuery(ctx context.Context, movieFilter *models.MovieFi movieFilter = &models.MovieFilterType{} } - query := qb.newQuery() + query := movieRepository.newQuery() distinctIDs(&query, movieTable) if q := findFilter.Q; q != nil && *q != "" { @@ -362,7 +356,9 @@ func (qb *MovieStore) makeQuery(ctx context.Context, movieFilter *models.MovieFi query.parseQueryString(searchColumns, *q) } - filter := qb.makeFilter(ctx, movieFilter) + filter := filterBuilderFromHandler(ctx, &movieFilterHandler{ + movieFilter: movieFilter, + }) if err := query.addFilter(filter); err != nil { return nil, err @@ -407,71 +403,6 @@ func (qb *MovieStore) QueryCount(ctx context.Context, movieFilter *models.MovieF return query.executeCount(ctx) } -func movieIsMissingCriterionHandler(qb *MovieStore, isMissing *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if isMissing != nil && *isMissing != "" { - switch *isMissing { - case "front_image": - f.addWhere("movies.front_image_blob IS NULL") - case "back_image": - f.addWhere("movies.back_image_blob IS NULL") - case "scenes": - f.addLeftJoin("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 moviePerformersCriterionHandler(qb *MovieStore, performers *models.MultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performers != nil { - if performers.Modifier == models.CriterionModifierIsNull || performers.Modifier == models.CriterionModifierNotNull { - var notClause string - if performers.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("movies_scenes", "", "movies.id = movies_scenes.movie_id") - f.addLeftJoin("performers_scenes", "", "movies_scenes.scene_id = performers_scenes.scene_id") - - f.addWhere(fmt.Sprintf("performers_scenes.performer_id IS %s NULL", notClause)) - return - } - - if len(performers.Value) == 0 { - return - } - - var args []interface{} - for _, arg := range performers.Value { - args = append(args, arg) - } - - // Hack, can't apply args to join, nor inner join on a left join, so use CTE instead - f.addWith(`movies_performers AS ( - SELECT movies_scenes.movie_id, performers_scenes.performer_id - FROM movies_scenes - INNER JOIN performers_scenes ON movies_scenes.scene_id = performers_scenes.scene_id - WHERE performers_scenes.performer_id IN`+getInBinding(len(performers.Value))+` - )`, args...) - f.addLeftJoin("movies_performers", "", "movies.id = movies_performers.movie_id") - - switch performers.Modifier { - case models.CriterionModifierIncludes: - f.addWhere("movies_performers.performer_id IS NOT NULL") - case models.CriterionModifierIncludesAll: - f.addWhere("movies_performers.performer_id IS NOT NULL") - f.addHaving("COUNT(DISTINCT movies_performers.performer_id) = ?", len(performers.Value)) - case models.CriterionModifierExcludes: - f.addWhere("movies_performers.performer_id IS NULL") - } - } - } -} - var movieSortOptions = sortOptions{ "created_at", "date", @@ -516,7 +447,7 @@ func (qb *MovieStore) getMovieSort(findFilter *models.FindFilterType) (string, e func (qb *MovieStore) queryMovies(ctx context.Context, query string, args []interface{}) ([]*models.Movie, error) { const single = false var ret []*models.Movie - if err := qb.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { + if err := movieRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { var f movieRow if err := r.StructScan(&f); err != nil { return err @@ -586,7 +517,7 @@ INNER JOIN performers_scenes ON performers_scenes.scene_id = movies_scenes.scene WHERE performers_scenes.performer_id = ? ` args := []interface{}{performerID} - return qb.runCountQuery(ctx, query, args) + return movieRepository.runCountQuery(ctx, query, args) } func (qb *MovieStore) FindByStudioID(ctx context.Context, studioID int) ([]*models.Movie, error) { @@ -604,5 +535,5 @@ FROM movies WHERE movies.studio_id = ? ` args := []interface{}{studioID} - return qb.runCountQuery(ctx, query, args) + return movieRepository.runCountQuery(ctx, query, args) } diff --git a/pkg/sqlite/movies_filter.go b/pkg/sqlite/movies_filter.go new file mode 100644 index 000000000..78d5abf5d --- /dev/null +++ b/pkg/sqlite/movies_filter.go @@ -0,0 +1,150 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +type movieFilterHandler struct { + movieFilter *models.MovieFilterType +} + +func (qb *movieFilterHandler) validate() error { + movieFilter := qb.movieFilter + if movieFilter == nil { + return nil + } + + if err := validateFilterCombination(movieFilter.OperatorFilter); err != nil { + return err + } + + if subFilter := movieFilter.SubFilter(); subFilter != nil { + sqb := &movieFilterHandler{movieFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + return nil +} + +func (qb *movieFilterHandler) handle(ctx context.Context, f *filterBuilder) { + movieFilter := qb.movieFilter + if movieFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := movieFilter.SubFilter() + if sf != nil { + sub := &movieFilterHandler{sf} + handleSubFilter(ctx, sub, f, movieFilter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *movieFilterHandler) criterionHandler() criterionHandler { + movieFilter := qb.movieFilter + return compoundHandler{ + stringCriterionHandler(movieFilter.Name, "movies.name"), + stringCriterionHandler(movieFilter.Director, "movies.director"), + stringCriterionHandler(movieFilter.Synopsis, "movies.synopsis"), + intCriterionHandler(movieFilter.Rating100, "movies.rating", nil), + floatIntCriterionHandler(movieFilter.Duration, "movies.duration", nil), + qb.missingCriterionHandler(movieFilter.IsMissing), + stringCriterionHandler(movieFilter.URL, "movies.url"), + studioCriterionHandler(movieTable, movieFilter.Studios), + qb.performersCriterionHandler(movieFilter.Performers), + &dateCriterionHandler{movieFilter.Date, "movies.date", nil}, + ×tampCriterionHandler{movieFilter.CreatedAt, "movies.created_at", nil}, + ×tampCriterionHandler{movieFilter.UpdatedAt, "movies.updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "movies_scenes.scene_id", + relatedRepo: sceneRepository.repository, + relatedHandler: &sceneFilterHandler{movieFilter.ScenesFilter}, + joinFn: func(f *filterBuilder) { + movieRepository.scenes.innerJoin(f, "", "movies.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "movies.studio_id", + relatedRepo: studioRepository.repository, + relatedHandler: &studioFilterHandler{movieFilter.StudiosFilter}, + }, + } +} + +func (qb *movieFilterHandler) missingCriterionHandler(isMissing *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "front_image": + f.addWhere("movies.front_image_blob IS NULL") + case "back_image": + f.addWhere("movies.back_image_blob IS NULL") + case "scenes": + f.addLeftJoin("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 (qb *movieFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performers != nil { + if performers.Modifier == models.CriterionModifierIsNull || performers.Modifier == models.CriterionModifierNotNull { + var notClause string + if performers.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addLeftJoin("movies_scenes", "", "movies.id = movies_scenes.movie_id") + f.addLeftJoin("performers_scenes", "", "movies_scenes.scene_id = performers_scenes.scene_id") + + f.addWhere(fmt.Sprintf("performers_scenes.performer_id IS %s NULL", notClause)) + return + } + + if len(performers.Value) == 0 { + return + } + + var args []interface{} + for _, arg := range performers.Value { + args = append(args, arg) + } + + // Hack, can't apply args to join, nor inner join on a left join, so use CTE instead + f.addWith(`movies_performers AS ( + SELECT movies_scenes.movie_id, performers_scenes.performer_id + FROM movies_scenes + INNER JOIN performers_scenes ON movies_scenes.scene_id = performers_scenes.scene_id + WHERE performers_scenes.performer_id IN`+getInBinding(len(performers.Value))+` + )`, args...) + f.addLeftJoin("movies_performers", "", "movies.id = movies_performers.movie_id") + + switch performers.Modifier { + case models.CriterionModifierIncludes: + f.addWhere("movies_performers.performer_id IS NOT NULL") + case models.CriterionModifierIncludesAll: + f.addWhere("movies_performers.performer_id IS NOT NULL") + f.addHaving("COUNT(DISTINCT movies_performers.performer_id) = ?", len(performers.Value)) + case models.CriterionModifierExcludes: + f.addWhere("movies_performers.performer_id IS NULL") + } + } + } +} diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index dcdc92f0f..4ba05168d 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -5,8 +5,6 @@ import ( "database/sql" "errors" "fmt" - "strconv" - "strings" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" @@ -176,8 +174,66 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { r.setBool("ignore_auto_tag", o.IgnoreAutoTag) } -type PerformerStore struct { +type performerRepositoryType struct { repository + + tags joinRepository + stashIDs stashIDRepository + + scenes joinRepository + images joinRepository + galleries joinRepository +} + +var ( + performerRepository = performerRepositoryType{ + repository: repository{ + tableName: performerTable, + idColumn: idColumn, + }, + tags: joinRepository{ + repository: repository{ + tableName: performersTagsTable, + idColumn: performerIDColumn, + }, + fkColumn: tagIDColumn, + foreignTable: tagTable, + orderBy: "tags.name ASC", + }, + stashIDs: stashIDRepository{ + repository{ + tableName: "performer_stash_ids", + idColumn: performerIDColumn, + }, + }, + scenes: joinRepository{ + repository: repository{ + tableName: performersScenesTable, + idColumn: performerIDColumn, + }, + fkColumn: sceneIDColumn, + foreignTable: sceneTable, + }, + images: joinRepository{ + repository: repository{ + tableName: performersImagesTable, + idColumn: performerIDColumn, + }, + fkColumn: imageIDColumn, + foreignTable: imageTable, + }, + galleries: joinRepository{ + repository: repository{ + tableName: performersGalleriesTable, + idColumn: performerIDColumn, + }, + fkColumn: galleryIDColumn, + foreignTable: galleryTable, + }, + } +) + +type PerformerStore struct { blobJoinQueryBuilder tableMgr *table @@ -185,10 +241,6 @@ type PerformerStore struct { func NewPerformerStore(blobStore *BlobStore) *PerformerStore { return &PerformerStore{ - repository: repository{ - tableName: performerTable, - idColumn: idColumn, - }, blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, joinTable: performerTable, @@ -312,7 +364,7 @@ func (qb *PerformerStore) Destroy(ctx context.Context, id int) error { return err } - return qb.destroyExisting(ctx, []int{id}) + return performerRepository.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found @@ -525,161 +577,6 @@ func (qb *PerformerStore) QueryForAutoTag(ctx context.Context, words []string) ( return ret, nil } -func (qb *PerformerStore) validateFilter(filter *models.PerformerFilterType) error { - const and = "AND" - const or = "OR" - const not = "NOT" - - if filter.And != nil { - if filter.Or != nil { - return illegalFilterCombination(and, or) - } - if filter.Not != nil { - return illegalFilterCombination(and, not) - } - - return qb.validateFilter(filter.And) - } - - if filter.Or != nil { - if filter.Not != nil { - return illegalFilterCombination(or, not) - } - - return qb.validateFilter(filter.Or) - } - - if filter.Not != nil { - return qb.validateFilter(filter.Not) - } - - // if legacy height filter used, ensure only supported modifiers are used - if filter.Height != nil { - // treat as an int filter - intCrit := &models.IntCriterionInput{ - Modifier: filter.Height.Modifier, - } - if !intCrit.ValidModifier() { - return fmt.Errorf("invalid height modifier: %s", filter.Height.Modifier) - } - - // ensure value is a valid number - if _, err := strconv.Atoi(filter.Height.Value); err != nil { - return fmt.Errorf("invalid height value: %s", filter.Height.Value) - } - } - - return nil -} - -func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.PerformerFilterType) *filterBuilder { - query := &filterBuilder{} - - if filter.And != nil { - query.and(qb.makeFilter(ctx, filter.And)) - } - if filter.Or != nil { - query.or(qb.makeFilter(ctx, filter.Or)) - } - if filter.Not != nil { - query.not(qb.makeFilter(ctx, filter.Not)) - } - - const tableName = performerTable - query.handleCriterion(ctx, stringCriterionHandler(filter.Name, tableName+".name")) - query.handleCriterion(ctx, stringCriterionHandler(filter.Disambiguation, tableName+".disambiguation")) - query.handleCriterion(ctx, stringCriterionHandler(filter.Details, tableName+".details")) - - query.handleCriterion(ctx, boolCriterionHandler(filter.FilterFavorites, tableName+".favorite", nil)) - query.handleCriterion(ctx, boolCriterionHandler(filter.IgnoreAutoTag, tableName+".ignore_auto_tag", nil)) - - query.handleCriterion(ctx, yearFilterCriterionHandler(filter.BirthYear, tableName+".birthdate")) - query.handleCriterion(ctx, yearFilterCriterionHandler(filter.DeathYear, tableName+".death_date")) - - query.handleCriterion(ctx, performerAgeFilterCriterionHandler(filter.Age)) - - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if gender := filter.Gender; gender != nil { - genderCopy := *gender - if genderCopy.Value.IsValid() && len(genderCopy.ValueList) == 0 { - genderCopy.ValueList = []models.GenderEnum{genderCopy.Value} - } - - v := utils.StringerSliceToStringSlice(genderCopy.ValueList) - enumCriterionHandler(genderCopy.Modifier, v, tableName+".gender")(ctx, f) - } - })) - - query.handleCriterion(ctx, performerIsMissingCriterionHandler(qb, filter.IsMissing)) - query.handleCriterion(ctx, stringCriterionHandler(filter.Ethnicity, tableName+".ethnicity")) - query.handleCriterion(ctx, stringCriterionHandler(filter.Country, tableName+".country")) - query.handleCriterion(ctx, stringCriterionHandler(filter.EyeColor, tableName+".eye_color")) - - // special handler for legacy height filter - heightCmCrit := filter.HeightCm - if heightCmCrit == nil && filter.Height != nil { - heightCm, _ := strconv.Atoi(filter.Height.Value) // already validated - heightCmCrit = &models.IntCriterionInput{ - Value: heightCm, - Modifier: filter.Height.Modifier, - } - } - - query.handleCriterion(ctx, intCriterionHandler(heightCmCrit, tableName+".height", nil)) - - query.handleCriterion(ctx, stringCriterionHandler(filter.Measurements, tableName+".measurements")) - query.handleCriterion(ctx, stringCriterionHandler(filter.FakeTits, tableName+".fake_tits")) - query.handleCriterion(ctx, floatCriterionHandler(filter.PenisLength, tableName+".penis_length", nil)) - - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if circumcised := filter.Circumcised; circumcised != nil { - v := utils.StringerSliceToStringSlice(circumcised.Value) - enumCriterionHandler(circumcised.Modifier, v, tableName+".circumcised")(ctx, f) - } - })) - - query.handleCriterion(ctx, stringCriterionHandler(filter.CareerLength, tableName+".career_length")) - query.handleCriterion(ctx, stringCriterionHandler(filter.Tattoos, tableName+".tattoos")) - query.handleCriterion(ctx, stringCriterionHandler(filter.Piercings, tableName+".piercings")) - query.handleCriterion(ctx, intCriterionHandler(filter.Rating100, tableName+".rating", nil)) - query.handleCriterion(ctx, stringCriterionHandler(filter.HairColor, tableName+".hair_color")) - query.handleCriterion(ctx, stringCriterionHandler(filter.URL, tableName+".url")) - query.handleCriterion(ctx, intCriterionHandler(filter.Weight, tableName+".weight", nil)) - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if filter.StashID != nil { - qb.stashIDRepository().join(f, "performer_stash_ids", "performers.id") - stringCriterionHandler(filter.StashID, "performer_stash_ids.stash_id")(ctx, f) - } - })) - query.handleCriterion(ctx, &stashIDCriterionHandler{ - c: filter.StashIDEndpoint, - stashIDRepository: qb.stashIDRepository(), - stashIDTableAs: "performer_stash_ids", - parentIDCol: "performers.id", - }) - - query.handleCriterion(ctx, performerAliasCriterionHandler(qb, filter.Aliases)) - - query.handleCriterion(ctx, performerTagsCriterionHandler(qb, filter.Tags)) - - query.handleCriterion(ctx, performerStudiosCriterionHandler(qb, filter.Studios)) - - query.handleCriterion(ctx, performerAppearsWithCriterionHandler(qb, filter.Performers)) - - query.handleCriterion(ctx, performerTagCountCriterionHandler(qb, filter.TagCount)) - query.handleCriterion(ctx, performerSceneCountCriterionHandler(qb, filter.SceneCount)) - query.handleCriterion(ctx, performerImageCountCriterionHandler(qb, filter.ImageCount)) - query.handleCriterion(ctx, performerGalleryCountCriterionHandler(qb, filter.GalleryCount)) - query.handleCriterion(ctx, performerPlayCounterCriterionHandler(qb, filter.PlayCount)) - query.handleCriterion(ctx, performerOCounterCriterionHandler(qb, filter.OCounter)) - query.handleCriterion(ctx, dateCriterionHandler(filter.Birthdate, tableName+".birthdate")) - query.handleCriterion(ctx, dateCriterionHandler(filter.DeathDate, tableName+".death_date")) - query.handleCriterion(ctx, timestampCriterionHandler(filter.CreatedAt, tableName+".created_at")) - query.handleCriterion(ctx, timestampCriterionHandler(filter.UpdatedAt, tableName+".updated_at")) - - return query -} - func (qb *PerformerStore) makeQuery(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if performerFilter == nil { performerFilter = &models.PerformerFilterType{} @@ -688,7 +585,7 @@ func (qb *PerformerStore) makeQuery(ctx context.Context, performerFilter *models findFilter = &models.FindFilterType{} } - query := qb.newQuery() + query := performerRepository.newQuery() distinctIDs(&query, performerTable) if q := findFilter.Q; q != nil && *q != "" { @@ -697,10 +594,9 @@ func (qb *PerformerStore) makeQuery(ctx context.Context, performerFilter *models query.parseQueryString(searchColumns, *q) } - if err := qb.validateFilter(performerFilter); err != nil { - return nil, err - } - filter := qb.makeFilter(ctx, performerFilter) + filter := filterBuilderFromHandler(ctx, &performerFilterHandler{ + performerFilter: performerFilter, + }) if err := query.addFilter(filter); err != nil { return nil, err @@ -744,165 +640,16 @@ func (qb *PerformerStore) QueryCount(ctx context.Context, performerFilter *model return query.executeCount(ctx) } -// TODO - we need to provide a whitelist of possible values -func performerIsMissingCriterionHandler(qb *PerformerStore, isMissing *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if isMissing != nil && *isMissing != "" { - switch *isMissing { - case "scenes": // Deprecated: use `scene_count == 0` filter instead - f.addLeftJoin(performersScenesTable, "scenes_join", "scenes_join.performer_id = performers.id") - f.addWhere("scenes_join.scene_id IS NULL") - case "image": - f.addWhere("performers.image_blob IS NULL") - case "stash_id": - performersStashIDsTableMgr.join(f, "performer_stash_ids", "performers.id") - f.addWhere("performer_stash_ids.performer_id IS NULL") - case "aliases": - performersAliasesTableMgr.join(f, "", "performers.id") - f.addWhere("performer_aliases.alias IS NULL") - default: - f.addWhere("(performers." + *isMissing + " IS NULL OR TRIM(performers." + *isMissing + ") = '')") - } - } - } +func (qb *PerformerStore) sortByOCounter(direction string) string { + // need to sum the o_counter from scenes and images + return " ORDER BY (" + selectPerformerOCountSQL + ") " + direction } -func yearFilterCriterionHandler(year *models.IntCriterionInput, col string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if year != nil && year.Modifier.IsValid() { - clause, args := getIntCriterionWhereClause("cast(strftime('%Y', "+col+") as int)", *year) - f.addWhere(clause, args...) - } - } +func (qb *PerformerStore) sortByPlayCount(direction string) string { + // need to sum the o_counter from scenes and images + return " ORDER BY (" + selectPerformerPlayCountSQL + ") " + direction } -func performerAgeFilterCriterionHandler(age *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if age != nil && age.Modifier.IsValid() { - clause, args := getIntCriterionWhereClause( - "cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int)", - *age, - ) - f.addWhere(clause, args...) - } - } -} - -func performerAliasCriterionHandler(qb *PerformerStore, alias *models.StringCriterionInput) criterionHandlerFunc { - h := stringListCriterionHandlerBuilder{ - joinTable: performersAliasesTable, - stringColumn: performerAliasColumn, - addJoinTable: func(f *filterBuilder) { - performersAliasesTableMgr.join(f, "", "performers.id") - }, - } - - return h.handler(alias) -} - -func performerTagsCriterionHandler(qb *PerformerStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := joinedHierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: performerTable, - foreignTable: tagTable, - foreignFK: "tag_id", - - relationsTable: "tags_relations", - joinAs: "image_tag", - joinTable: performersTagsTable, - primaryFK: performerIDColumn, - } - - return h.handler(tags) -} - -func performerTagCountCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: performerTable, - joinTable: performersTagsTable, - primaryFK: performerIDColumn, - } - - return h.handler(count) -} - -func performerSceneCountCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: performerTable, - joinTable: performersScenesTable, - primaryFK: performerIDColumn, - } - - return h.handler(count) -} - -func performerImageCountCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: performerTable, - joinTable: performersImagesTable, - primaryFK: performerIDColumn, - } - - return h.handler(count) -} - -func performerGalleryCountCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: performerTable, - joinTable: performersGalleriesTable, - primaryFK: performerIDColumn, - } - - return h.handler(count) -} - -// used for sorting and filtering on performer o-count -var selectPerformerOCountSQL = utils.StrFormat( - "SELECT SUM(o_counter) "+ - "FROM ("+ - "SELECT SUM(o_counter) as o_counter from {performers_images} s "+ - "LEFT JOIN {images} ON {images}.id = s.{images_id} "+ - "WHERE s.{performer_id} = {performers}.id "+ - "UNION ALL "+ - "SELECT COUNT({scenes_o_dates}.{o_date}) as o_counter from {performers_scenes} s "+ - "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ - "LEFT JOIN {scenes_o_dates} ON {scenes_o_dates}.{scene_id} = {scenes}.id "+ - "WHERE s.{performer_id} = {performers}.id "+ - ")", - map[string]interface{}{ - "performers_images": performersImagesTable, - "images": imageTable, - "performer_id": performerIDColumn, - "images_id": imageIDColumn, - "performers": performerTable, - "performers_scenes": performersScenesTable, - "scenes": sceneTable, - "scene_id": sceneIDColumn, - "scenes_o_dates": scenesODatesTable, - "o_date": sceneODateColumn, - }, -) - -// used for sorting and filtering play count on performer view count -var selectPerformerPlayCountSQL = utils.StrFormat( - "SELECT COUNT(DISTINCT {view_date}) FROM ("+ - "SELECT {view_date} FROM {performers_scenes} s "+ - "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ - "LEFT JOIN {scenes_view_dates} ON {scenes_view_dates}.{scene_id} = {scenes}.id "+ - "WHERE s.{performer_id} = {performers}.id"+ - ")", - map[string]interface{}{ - "performer_id": performerIDColumn, - "performers": performerTable, - "performers_scenes": performersScenesTable, - "scenes": sceneTable, - "scene_id": sceneIDColumn, - "scenes_view_dates": scenesViewDatesTable, - "view_date": sceneViewDateColumn, - }, -) - // used for sorting on performer last o_date var selectPerformerLastOAtSQL = utils.StrFormat( "SELECT MAX(o_date) FROM ("+ @@ -922,6 +669,11 @@ var selectPerformerLastOAtSQL = utils.StrFormat( }, ) +func (qb *PerformerStore) sortByLastOAt(direction string) string { + // need to get the o_dates from scenes + return " ORDER BY (" + selectPerformerLastOAtSQL + ") " + direction +} + // used for sorting on performer last view_date var selectPerformerLastPlayedAtSQL = utils.StrFormat( "SELECT MAX(view_date) FROM ("+ @@ -941,182 +693,6 @@ var selectPerformerLastPlayedAtSQL = utils.StrFormat( }, ) -func performerOCounterCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if count == nil { - return - } - - lhs := "(" + selectPerformerOCountSQL + ")" - clause, args := getIntCriterionWhereClause(lhs, *count) - - f.addWhere(clause, args...) - } -} - -func performerPlayCounterCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if count == nil { - return - } - - lhs := "(" + selectPerformerPlayCountSQL + ")" - clause, args := getIntCriterionWhereClause(lhs, *count) - - f.addWhere(clause, args...) - } -} - -func performerStudiosCriterionHandler(qb *PerformerStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if studios != nil { - formatMaps := []utils.StrFormatMap{ - { - "primaryTable": sceneTable, - "joinTable": performersScenesTable, - "primaryFK": sceneIDColumn, - }, - { - "primaryTable": imageTable, - "joinTable": performersImagesTable, - "primaryFK": imageIDColumn, - }, - { - "primaryTable": galleryTable, - "joinTable": performersGalleriesTable, - "primaryFK": galleryIDColumn, - }, - } - - if studios.Modifier == models.CriterionModifierIsNull || studios.Modifier == models.CriterionModifierNotNull { - var notClause string - if studios.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - var conditions []string - for _, c := range formatMaps { - f.addLeftJoin(c["joinTable"].(string), "", fmt.Sprintf("%s.performer_id = performers.id", c["joinTable"])) - f.addLeftJoin(c["primaryTable"].(string), "", fmt.Sprintf("%s.%s = %s.id", c["joinTable"], c["primaryFK"], c["primaryTable"])) - - conditions = append(conditions, fmt.Sprintf("%s.studio_id IS NULL", c["primaryTable"])) - } - - f.addWhere(fmt.Sprintf("%s (%s)", notClause, strings.Join(conditions, " AND "))) - return - } - - if len(studios.Value) == 0 { - return - } - - var clauseCondition string - - switch studios.Modifier { - case models.CriterionModifierIncludes: - // return performers who appear in scenes/images/galleries with any of the given studios - clauseCondition = "NOT" - case models.CriterionModifierExcludes: - // exclude performers who appear in scenes/images/galleries with any of the given studios - clauseCondition = "" - default: - return - } - - const derivedPerformerStudioTable = "performer_studio" - valuesClause, err := getHierarchicalValues(ctx, qb.tx, studios.Value, studioTable, "", "parent_id", "child_id", studios.Depth) - if err != nil { - f.setError(err) - return - } - f.addWith("studio(root_id, item_id) AS (" + valuesClause + ")") - - templStr := `SELECT performer_id FROM {primaryTable} - INNER JOIN {joinTable} ON {primaryTable}.id = {joinTable}.{primaryFK} - INNER JOIN studio ON {primaryTable}.studio_id = studio.item_id` - - var unions []string - for _, c := range formatMaps { - unions = append(unions, utils.StrFormat(templStr, c)) - } - - f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerStudioTable, strings.Join(unions, " UNION "))) - - f.addLeftJoin(derivedPerformerStudioTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerStudioTable)) - f.addWhere(fmt.Sprintf("%s.performer_id IS %s NULL", derivedPerformerStudioTable, clauseCondition)) - } - } -} - -func performerAppearsWithCriterionHandler(qb *PerformerStore, performers *models.MultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performers != nil { - formatMaps := []utils.StrFormatMap{ - { - "primaryTable": performersScenesTable, - "joinTable": performersScenesTable, - "primaryFK": sceneIDColumn, - }, - { - "primaryTable": performersImagesTable, - "joinTable": performersImagesTable, - "primaryFK": imageIDColumn, - }, - { - "primaryTable": performersGalleriesTable, - "joinTable": performersGalleriesTable, - "primaryFK": galleryIDColumn, - }, - } - - if len(performers.Value) == '0' { - return - } - - const derivedPerformerPerformersTable = "performer_performers" - - valuesClause := strings.Join(performers.Value, "),(") - - f.addWith("performer(id) AS (VALUES(" + valuesClause + "))") - - templStr := `SELECT {primaryTable}2.performer_id FROM {primaryTable} - INNER JOIN {primaryTable} AS {primaryTable}2 ON {primaryTable}.{primaryFK} = {primaryTable}2.{primaryFK} - INNER JOIN performer ON {primaryTable}.performer_id = performer.id - WHERE {primaryTable}2.performer_id != performer.id` - - if performers.Modifier == models.CriterionModifierIncludesAll && len(performers.Value) > 1 { - templStr += ` - GROUP BY {primaryTable}2.performer_id - HAVING(count(distinct {primaryTable}.performer_id) IS ` + strconv.Itoa(len(performers.Value)) + `)` - } - - var unions []string - for _, c := range formatMaps { - unions = append(unions, utils.StrFormat(templStr, c)) - } - - f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerPerformersTable, strings.Join(unions, " UNION "))) - - f.addInnerJoin(derivedPerformerPerformersTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerPerformersTable)) - } - } -} - -func (qb *PerformerStore) sortByOCounter(direction string) string { - // need to sum the o_counter from scenes and images - return " ORDER BY (" + selectPerformerOCountSQL + ") " + direction -} - -func (qb *PerformerStore) sortByPlayCount(direction string) string { - // need to sum the o_counter from scenes and images - return " ORDER BY (" + selectPerformerPlayCountSQL + ") " + direction -} - -func (qb *PerformerStore) sortByLastOAt(direction string) string { - // need to get the o_dates from scenes - return " ORDER BY (" + selectPerformerLastOAtSQL + ") " + direction -} - func (qb *PerformerStore) sortByLastPlayedAt(direction string) string { // need to get the view_dates from scenes return " ORDER BY (" + selectPerformerLastPlayedAtSQL + ") " + direction @@ -1185,21 +761,8 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s return sortQuery, nil } -func (qb *PerformerStore) tagsRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: performersTagsTable, - idColumn: performerIDColumn, - }, - fkColumn: tagIDColumn, - foreignTable: tagTable, - orderBy: "tags.name ASC", - } -} - func (qb *PerformerStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { - return qb.tagsRepository().getIDs(ctx, id) + return performerRepository.tags.getIDs(ctx, id) } func (qb *PerformerStore) GetImage(ctx context.Context, performerID int) ([]byte, error) { @@ -1218,16 +781,6 @@ func (qb *PerformerStore) destroyImage(ctx context.Context, performerID int) err return qb.blobJoinQueryBuilder.DestroyImage(ctx, performerID, performerImageBlobColumn) } -func (qb *PerformerStore) stashIDRepository() *stashIDRepository { - return &stashIDRepository{ - repository{ - tx: qb.tx, - tableName: "performer_stash_ids", - idColumn: performerIDColumn, - }, - } -} - func (qb *PerformerStore) GetAliases(ctx context.Context, performerID int) ([]string, error) { return performersAliasesTableMgr.get(ctx, performerID) } diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go new file mode 100644 index 000000000..100da4244 --- /dev/null +++ b/pkg/sqlite/performer_filter.go @@ -0,0 +1,516 @@ +package sqlite + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" +) + +type performerFilterHandler struct { + performerFilter *models.PerformerFilterType +} + +func (qb *performerFilterHandler) validate() error { + filter := qb.performerFilter + if filter == nil { + return nil + } + + if err := validateFilterCombination(filter.OperatorFilter); err != nil { + return err + } + + if subFilter := filter.SubFilter(); subFilter != nil { + sqb := &performerFilterHandler{performerFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + // if legacy height filter used, ensure only supported modifiers are used + if filter.Height != nil { + // treat as an int filter + intCrit := &models.IntCriterionInput{ + Modifier: filter.Height.Modifier, + } + if !intCrit.ValidModifier() { + return fmt.Errorf("invalid height modifier: %s", filter.Height.Modifier) + } + + // ensure value is a valid number + if _, err := strconv.Atoi(filter.Height.Value); err != nil { + return fmt.Errorf("invalid height value: %s", filter.Height.Value) + } + } + + return nil +} + +func (qb *performerFilterHandler) handle(ctx context.Context, f *filterBuilder) { + filter := qb.performerFilter + if filter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := filter.SubFilter() + if sf != nil { + sub := &performerFilterHandler{sf} + handleSubFilter(ctx, sub, f, filter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *performerFilterHandler) criterionHandler() criterionHandler { + filter := qb.performerFilter + const tableName = performerTable + heightCmCrit := filter.HeightCm + + return compoundHandler{ + stringCriterionHandler(filter.Name, tableName+".name"), + stringCriterionHandler(filter.Disambiguation, tableName+".disambiguation"), + stringCriterionHandler(filter.Details, tableName+".details"), + + boolCriterionHandler(filter.FilterFavorites, tableName+".favorite", nil), + boolCriterionHandler(filter.IgnoreAutoTag, tableName+".ignore_auto_tag", nil), + + yearFilterCriterionHandler(filter.BirthYear, tableName+".birthdate"), + yearFilterCriterionHandler(filter.DeathYear, tableName+".death_date"), + + qb.performerAgeFilterCriterionHandler(filter.Age), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if gender := filter.Gender; gender != nil { + genderCopy := *gender + if genderCopy.Value.IsValid() && len(genderCopy.ValueList) == 0 { + genderCopy.ValueList = []models.GenderEnum{genderCopy.Value} + } + + v := utils.StringerSliceToStringSlice(genderCopy.ValueList) + enumCriterionHandler(genderCopy.Modifier, v, tableName+".gender")(ctx, f) + } + }), + + qb.performerIsMissingCriterionHandler(filter.IsMissing), + stringCriterionHandler(filter.Ethnicity, tableName+".ethnicity"), + stringCriterionHandler(filter.Country, tableName+".country"), + stringCriterionHandler(filter.EyeColor, tableName+".eye_color"), + + // special handler for legacy height filter + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if heightCmCrit == nil && filter.Height != nil { + heightCm, _ := strconv.Atoi(filter.Height.Value) // already validated + heightCmCrit = &models.IntCriterionInput{ + Value: heightCm, + Modifier: filter.Height.Modifier, + } + } + }), + + intCriterionHandler(heightCmCrit, tableName+".height", nil), + + stringCriterionHandler(filter.Measurements, tableName+".measurements"), + stringCriterionHandler(filter.FakeTits, tableName+".fake_tits"), + floatCriterionHandler(filter.PenisLength, tableName+".penis_length", nil), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if circumcised := filter.Circumcised; circumcised != nil { + v := utils.StringerSliceToStringSlice(circumcised.Value) + enumCriterionHandler(circumcised.Modifier, v, tableName+".circumcised")(ctx, f) + } + }), + + stringCriterionHandler(filter.CareerLength, tableName+".career_length"), + stringCriterionHandler(filter.Tattoos, tableName+".tattoos"), + stringCriterionHandler(filter.Piercings, tableName+".piercings"), + intCriterionHandler(filter.Rating100, tableName+".rating", nil), + stringCriterionHandler(filter.HairColor, tableName+".hair_color"), + stringCriterionHandler(filter.URL, tableName+".url"), + intCriterionHandler(filter.Weight, tableName+".weight", nil), + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if filter.StashID != nil { + performerRepository.stashIDs.join(f, "performer_stash_ids", "performers.id") + stringCriterionHandler(filter.StashID, "performer_stash_ids.stash_id")(ctx, f) + } + }), + &stashIDCriterionHandler{ + c: filter.StashIDEndpoint, + stashIDRepository: &performerRepository.stashIDs, + stashIDTableAs: "performer_stash_ids", + parentIDCol: "performers.id", + }, + + qb.aliasCriterionHandler(filter.Aliases), + + qb.tagsCriterionHandler(filter.Tags), + + qb.studiosCriterionHandler(filter.Studios), + + qb.appearsWithCriterionHandler(filter.Performers), + + qb.tagCountCriterionHandler(filter.TagCount), + qb.sceneCountCriterionHandler(filter.SceneCount), + qb.imageCountCriterionHandler(filter.ImageCount), + qb.galleryCountCriterionHandler(filter.GalleryCount), + qb.playCounterCriterionHandler(filter.PlayCount), + qb.oCounterCriterionHandler(filter.OCounter), + &dateCriterionHandler{filter.Birthdate, tableName + ".birthdate", nil}, + &dateCriterionHandler{filter.DeathDate, tableName + ".death_date", nil}, + ×tampCriterionHandler{filter.CreatedAt, tableName + ".created_at", nil}, + ×tampCriterionHandler{filter.UpdatedAt, tableName + ".updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "performers_scenes.scene_id", + relatedRepo: sceneRepository.repository, + relatedHandler: &sceneFilterHandler{filter.ScenesFilter}, + joinFn: func(f *filterBuilder) { + performerRepository.scenes.innerJoin(f, "", "performers.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "performers_images.image_id", + relatedRepo: imageRepository.repository, + relatedHandler: &imageFilterHandler{filter.ImagesFilter}, + joinFn: func(f *filterBuilder) { + performerRepository.images.innerJoin(f, "", "performers.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "performers_galleries.gallery_id", + relatedRepo: galleryRepository.repository, + relatedHandler: &galleryFilterHandler{filter.GalleriesFilter}, + joinFn: func(f *filterBuilder) { + performerRepository.galleries.innerJoin(f, "", "performers.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "performer_tag.tag_id", + relatedRepo: tagRepository.repository, + relatedHandler: &tagFilterHandler{filter.TagsFilter}, + joinFn: func(f *filterBuilder) { + performerRepository.tags.innerJoin(f, "performer_tag", "performers.id") + }, + }, + } +} + +// TODO - we need to provide a whitelist of possible values +func (qb *performerFilterHandler) performerIsMissingCriterionHandler(isMissing *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "scenes": // Deprecated: use `scene_count == 0` filter instead + f.addLeftJoin(performersScenesTable, "scenes_join", "scenes_join.performer_id = performers.id") + f.addWhere("scenes_join.scene_id IS NULL") + case "image": + f.addWhere("performers.image_blob IS NULL") + case "stash_id": + performersStashIDsTableMgr.join(f, "performer_stash_ids", "performers.id") + f.addWhere("performer_stash_ids.performer_id IS NULL") + case "aliases": + performersAliasesTableMgr.join(f, "", "performers.id") + f.addWhere("performer_aliases.alias IS NULL") + default: + f.addWhere("(performers." + *isMissing + " IS NULL OR TRIM(performers." + *isMissing + ") = '')") + } + } + } +} + +func (qb *performerFilterHandler) performerAgeFilterCriterionHandler(age *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if age != nil && age.Modifier.IsValid() { + clause, args := getIntCriterionWhereClause( + "cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int)", + *age, + ) + f.addWhere(clause, args...) + } + } +} + +func (qb *performerFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: performersAliasesTable, + stringColumn: performerAliasColumn, + addJoinTable: func(f *filterBuilder) { + performersAliasesTableMgr.join(f, "", "performers.id") + }, + } + + return h.handler(alias) +} + +func (qb *performerFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: performerTable, + foreignTable: tagTable, + foreignFK: "tag_id", + + relationsTable: "tags_relations", + joinAs: "performer_tag", + joinTable: performersTagsTable, + primaryFK: performerIDColumn, + } + + return h.handler(tags) +} + +func (qb *performerFilterHandler) tagCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: performerTable, + joinTable: performersTagsTable, + primaryFK: performerIDColumn, + } + + return h.handler(count) +} + +func (qb *performerFilterHandler) sceneCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: performerTable, + joinTable: performersScenesTable, + primaryFK: performerIDColumn, + } + + return h.handler(count) +} + +func (qb *performerFilterHandler) imageCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: performerTable, + joinTable: performersImagesTable, + primaryFK: performerIDColumn, + } + + return h.handler(count) +} + +func (qb *performerFilterHandler) galleryCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: performerTable, + joinTable: performersGalleriesTable, + primaryFK: performerIDColumn, + } + + return h.handler(count) +} + +// used for sorting and filtering on performer o-count +var selectPerformerOCountSQL = utils.StrFormat( + "SELECT SUM(o_counter) "+ + "FROM ("+ + "SELECT SUM(o_counter) as o_counter from {performers_images} s "+ + "LEFT JOIN {images} ON {images}.id = s.{images_id} "+ + "WHERE s.{performer_id} = {performers}.id "+ + "UNION ALL "+ + "SELECT COUNT({scenes_o_dates}.{o_date}) as o_counter from {performers_scenes} s "+ + "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ + "LEFT JOIN {scenes_o_dates} ON {scenes_o_dates}.{scene_id} = {scenes}.id "+ + "WHERE s.{performer_id} = {performers}.id "+ + ")", + map[string]interface{}{ + "performers_images": performersImagesTable, + "images": imageTable, + "performer_id": performerIDColumn, + "images_id": imageIDColumn, + "performers": performerTable, + "performers_scenes": performersScenesTable, + "scenes": sceneTable, + "scene_id": sceneIDColumn, + "scenes_o_dates": scenesODatesTable, + "o_date": sceneODateColumn, + }, +) + +// used for sorting and filtering play count on performer view count +var selectPerformerPlayCountSQL = utils.StrFormat( + "SELECT COUNT(DISTINCT {view_date}) FROM ("+ + "SELECT {view_date} FROM {performers_scenes} s "+ + "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ + "LEFT JOIN {scenes_view_dates} ON {scenes_view_dates}.{scene_id} = {scenes}.id "+ + "WHERE s.{performer_id} = {performers}.id"+ + ")", + map[string]interface{}{ + "performer_id": performerIDColumn, + "performers": performerTable, + "performers_scenes": performersScenesTable, + "scenes": sceneTable, + "scene_id": sceneIDColumn, + "scenes_view_dates": scenesViewDatesTable, + "view_date": sceneViewDateColumn, + }, +) + +func (qb *performerFilterHandler) oCounterCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if count == nil { + return + } + + lhs := "(" + selectPerformerOCountSQL + ")" + clause, args := getIntCriterionWhereClause(lhs, *count) + + f.addWhere(clause, args...) + } +} + +func (qb *performerFilterHandler) playCounterCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if count == nil { + return + } + + lhs := "(" + selectPerformerPlayCountSQL + ")" + clause, args := getIntCriterionWhereClause(lhs, *count) + + f.addWhere(clause, args...) + } +} + +func (qb *performerFilterHandler) studiosCriterionHandler(studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if studios != nil { + formatMaps := []utils.StrFormatMap{ + { + "primaryTable": sceneTable, + "joinTable": performersScenesTable, + "primaryFK": sceneIDColumn, + }, + { + "primaryTable": imageTable, + "joinTable": performersImagesTable, + "primaryFK": imageIDColumn, + }, + { + "primaryTable": galleryTable, + "joinTable": performersGalleriesTable, + "primaryFK": galleryIDColumn, + }, + } + + if studios.Modifier == models.CriterionModifierIsNull || studios.Modifier == models.CriterionModifierNotNull { + var notClause string + if studios.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + var conditions []string + for _, c := range formatMaps { + f.addLeftJoin(c["joinTable"].(string), "", fmt.Sprintf("%s.performer_id = performers.id", c["joinTable"])) + f.addLeftJoin(c["primaryTable"].(string), "", fmt.Sprintf("%s.%s = %s.id", c["joinTable"], c["primaryFK"], c["primaryTable"])) + + conditions = append(conditions, fmt.Sprintf("%s.studio_id IS NULL", c["primaryTable"])) + } + + f.addWhere(fmt.Sprintf("%s (%s)", notClause, strings.Join(conditions, " AND "))) + return + } + + if len(studios.Value) == 0 { + return + } + + var clauseCondition string + + switch studios.Modifier { + case models.CriterionModifierIncludes: + // return performers who appear in scenes/images/galleries with any of the given studios + clauseCondition = "NOT" + case models.CriterionModifierExcludes: + // exclude performers who appear in scenes/images/galleries with any of the given studios + clauseCondition = "" + default: + return + } + + const derivedPerformerStudioTable = "performer_studio" + valuesClause, err := getHierarchicalValues(ctx, studios.Value, studioTable, "", "parent_id", "child_id", studios.Depth) + if err != nil { + f.setError(err) + return + } + f.addWith("studio(root_id, item_id) AS (" + valuesClause + ")") + + templStr := `SELECT performer_id FROM {primaryTable} + INNER JOIN {joinTable} ON {primaryTable}.id = {joinTable}.{primaryFK} + INNER JOIN studio ON {primaryTable}.studio_id = studio.item_id` + + var unions []string + for _, c := range formatMaps { + unions = append(unions, utils.StrFormat(templStr, c)) + } + + f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerStudioTable, strings.Join(unions, " UNION "))) + + f.addLeftJoin(derivedPerformerStudioTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerStudioTable)) + f.addWhere(fmt.Sprintf("%s.performer_id IS %s NULL", derivedPerformerStudioTable, clauseCondition)) + } + } +} + +func (qb *performerFilterHandler) appearsWithCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performers != nil { + formatMaps := []utils.StrFormatMap{ + { + "primaryTable": performersScenesTable, + "joinTable": performersScenesTable, + "primaryFK": sceneIDColumn, + }, + { + "primaryTable": performersImagesTable, + "joinTable": performersImagesTable, + "primaryFK": imageIDColumn, + }, + { + "primaryTable": performersGalleriesTable, + "joinTable": performersGalleriesTable, + "primaryFK": galleryIDColumn, + }, + } + + if len(performers.Value) == '0' { + return + } + + const derivedPerformerPerformersTable = "performer_performers" + + valuesClause := strings.Join(performers.Value, "),(") + + f.addWith("performer(id) AS (VALUES(" + valuesClause + "))") + + templStr := `SELECT {primaryTable}2.performer_id FROM {primaryTable} + INNER JOIN {primaryTable} AS {primaryTable}2 ON {primaryTable}.{primaryFK} = {primaryTable}2.{primaryFK} + INNER JOIN performer ON {primaryTable}.performer_id = performer.id + WHERE {primaryTable}2.performer_id != performer.id` + + if performers.Modifier == models.CriterionModifierIncludesAll && len(performers.Value) > 1 { + templStr += ` + GROUP BY {primaryTable}2.performer_id + HAVING(count(distinct {primaryTable}.performer_id) IS ` + strconv.Itoa(len(performers.Value)) + `)` + } + + var unions []string + for _, c := range formatMaps { + unions = append(unions, utils.StrFormat(templStr, c)) + } + + f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerPerformersTable, strings.Join(unions, " UNION "))) + + f.addInnerJoin(derivedPerformerPerformersTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerPerformersTable)) + } + } +} diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 8ba32964b..d333913d2 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -731,10 +731,12 @@ func TestPerformerQueryEthnicityOr(t *testing.T) { Value: performer1Eth, Modifier: models.CriterionModifierEquals, }, - Or: &models.PerformerFilterType{ - Ethnicity: &models.StringCriterionInput{ - Value: performer2Eth, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ + Or: &models.PerformerFilterType{ + Ethnicity: &models.StringCriterionInput{ + Value: performer2Eth, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -760,10 +762,12 @@ func TestPerformerQueryEthnicityAndRating(t *testing.T) { Value: performerEth, Modifier: models.CriterionModifierEquals, }, - And: &models.PerformerFilterType{ - Rating100: &models.IntCriterionInput{ - Value: performerRating, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ + And: &models.PerformerFilterType{ + Rating100: &models.IntCriterionInput{ + Value: performerRating, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -801,8 +805,10 @@ func TestPerformerQueryEthnicityNotRating(t *testing.T) { performerFilter := models.PerformerFilterType{ Ethnicity: ðCriterion, - Not: &models.PerformerFilterType{ - Rating100: &ratingCriterion, + OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ + Not: &models.PerformerFilterType{ + Rating100: &ratingCriterion, + }, }, } @@ -838,24 +844,30 @@ func TestPerformerIllegalQuery(t *testing.T) { // And and Or in the same filter "AndOr", models.PerformerFilterType{ - And: &subFilter, - Or: &subFilter, + OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ + And: &subFilter, + Or: &subFilter, + }, }, }, { // And and Not in the same filter "AndNot", models.PerformerFilterType{ - And: &subFilter, - Not: &subFilter, + OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ + And: &subFilter, + Not: &subFilter, + }, }, }, { // Or and Not in the same filter "OrNot", models.PerformerFilterType{ - Or: &subFilter, - Not: &subFilter, + OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ + Or: &subFilter, + Not: &subFilter, + }, }, }, { diff --git a/pkg/sqlite/repository.go b/pkg/sqlite/repository.go index fe0961ff5..8eb87b9af 100644 --- a/pkg/sqlite/repository.go +++ b/pkg/sqlite/repository.go @@ -20,7 +20,6 @@ type objectList interface { } type repository struct { - tx dbWrapper tableName string idColumn string } @@ -48,7 +47,7 @@ func (r *repository) destroyExisting(ctx context.Context, ids []int) error { func (r *repository) destroy(ctx context.Context, ids []int) error { for _, id := range ids { stmt := fmt.Sprintf("DELETE FROM %s WHERE %s = ?", r.tableName, r.idColumn) - if _, err := r.tx.Exec(ctx, stmt, id); err != nil { + if _, err := dbWrapper.Exec(ctx, stmt, id); err != nil { return err } } @@ -78,7 +77,7 @@ func (r *repository) runCountQuery(ctx context.Context, query string, args []int }{0} // Perform query and fetch result - if err := r.tx.Get(ctx, &result, query, args...); err != nil && !errors.Is(err, sql.ErrNoRows) { + if err := dbWrapper.Get(ctx, &result, query, args...); err != nil && !errors.Is(err, sql.ErrNoRows) { return 0, err } @@ -90,7 +89,7 @@ func (r *repository) runIdsQuery(ctx context.Context, query string, args []inter Int int `db:"id"` } - if err := r.tx.Select(ctx, &result, query, args...); err != nil && !errors.Is(err, sql.ErrNoRows) { + if err := dbWrapper.Select(ctx, &result, query, args...); err != nil && !errors.Is(err, sql.ErrNoRows) { return []int{}, fmt.Errorf("running query: %s [%v]: %w", query, args, err) } @@ -102,7 +101,7 @@ func (r *repository) runIdsQuery(ctx context.Context, query string, args []inter } func (r *repository) queryFunc(ctx context.Context, query string, args []interface{}, single bool, f func(rows *sqlx.Rows) error) error { - rows, err := r.tx.Queryx(ctx, query, args...) + rows, err := dbWrapper.Queryx(ctx, query, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err @@ -150,7 +149,7 @@ func (r *repository) queryStruct(ctx context.Context, query string, args []inter } func (r *repository) querySimple(ctx context.Context, query string, args []interface{}, out interface{}) error { - rows, err := r.tx.Queryx(ctx, query, args...) + rows, err := dbWrapper.Queryx(ctx, query, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err @@ -230,7 +229,6 @@ func (r *repository) join(j joiner, as string, parentIDCol string) { j.addLeftJoin(r.tableName, as, fmt.Sprintf("%s.%s = %s", t, r.idColumn, parentIDCol)) } -//nolint:golint,unused func (r *repository) innerJoin(j joiner, as string, parentIDCol string) { t := r.tableName if as != "" { @@ -269,7 +267,7 @@ func (r *joinRepository) getIDs(ctx context.Context, id int) ([]int, error) { } func (r *joinRepository) insert(ctx context.Context, id int, foreignIDs ...int) error { - stmt, err := r.tx.Prepare(ctx, fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?)", r.tableName, r.idColumn, r.fkColumn)) + stmt, err := dbWrapper.Prepare(ctx, fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?)", r.tableName, r.idColumn, r.fkColumn)) if err != nil { return err } @@ -277,7 +275,7 @@ func (r *joinRepository) insert(ctx context.Context, id int, foreignIDs ...int) defer stmt.Close() for _, fk := range foreignIDs { - if _, err := r.tx.ExecStmt(ctx, stmt, id, fk); err != nil { + if _, err := dbWrapper.ExecStmt(ctx, stmt, id, fk); err != nil { return err } } @@ -286,7 +284,7 @@ func (r *joinRepository) insert(ctx context.Context, id int, foreignIDs ...int) // insertOrIgnore inserts a join into the table, silently failing in the event that a conflict occurs (ie when the join already exists) func (r *joinRepository) insertOrIgnore(ctx context.Context, id int, foreignIDs ...int) error { - stmt, err := r.tx.Prepare(ctx, fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?) ON CONFLICT (%[2]s, %s) DO NOTHING", r.tableName, r.idColumn, r.fkColumn)) + stmt, err := dbWrapper.Prepare(ctx, fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?) ON CONFLICT (%[2]s, %s) DO NOTHING", r.tableName, r.idColumn, r.fkColumn)) if err != nil { return err } @@ -294,7 +292,7 @@ func (r *joinRepository) insertOrIgnore(ctx context.Context, id int, foreignIDs defer stmt.Close() for _, fk := range foreignIDs { - if _, err := r.tx.ExecStmt(ctx, stmt, id, fk); err != nil { + if _, err := dbWrapper.ExecStmt(ctx, stmt, id, fk); err != nil { return err } } @@ -310,7 +308,7 @@ func (r *joinRepository) destroyJoins(ctx context.Context, id int, foreignIDs .. args[i+1] = v } - if _, err := r.tx.Exec(ctx, stmt, args...); err != nil { + if _, err := dbWrapper.Exec(ctx, stmt, args...); err != nil { return err } @@ -360,7 +358,7 @@ func (r *captionRepository) get(ctx context.Context, id models.FileID) ([]*model func (r *captionRepository) insert(ctx context.Context, id models.FileID, caption *models.VideoCaption) (sql.Result, error) { stmt := fmt.Sprintf("INSERT INTO %s (%s, %s, %s, %s) VALUES (?, ?, ?, ?)", r.tableName, r.idColumn, captionCodeColumn, captionFilenameColumn, captionTypeColumn) - return r.tx.Exec(ctx, stmt, id, caption.LanguageCode, caption.Filename, caption.CaptionType) + return dbWrapper.Exec(ctx, stmt, id, caption.LanguageCode, caption.Filename, caption.CaptionType) } func (r *captionRepository) replace(ctx context.Context, id models.FileID, captions []*models.VideoCaption) error { @@ -399,7 +397,7 @@ func (r *stringRepository) get(ctx context.Context, id int) ([]string, error) { func (r *stringRepository) insert(ctx context.Context, id int, s string) (sql.Result, error) { stmt := fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?)", r.tableName, r.idColumn, r.stringColumn) - return r.tx.Exec(ctx, stmt, id, s) + return dbWrapper.Exec(ctx, stmt, id, s) } func (r *stringRepository) replace(ctx context.Context, id int, newStrings []string) error { diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 8c35d162c..a6b73ac2e 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -168,23 +168,78 @@ func (r *sceneRowRecord) fromPartial(o models.ScenePartial) { r.setFloat64("play_duration", o.PlayDuration) } -type SceneStore struct { +type sceneRepositoryType struct { repository + galleries joinRepository + tags joinRepository + performers joinRepository + movies repository + + files filesRepository + + stashIDs stashIDRepository +} + +var ( + sceneRepository = sceneRepositoryType{ + repository: repository{ + tableName: sceneTable, + idColumn: idColumn, + }, + galleries: joinRepository{ + repository: repository{ + tableName: scenesGalleriesTable, + idColumn: sceneIDColumn, + }, + fkColumn: galleryIDColumn, + }, + tags: joinRepository{ + repository: repository{ + tableName: scenesTagsTable, + idColumn: sceneIDColumn, + }, + fkColumn: tagIDColumn, + foreignTable: tagTable, + orderBy: "tags.name ASC", + }, + performers: joinRepository{ + repository: repository{ + tableName: performersScenesTable, + idColumn: sceneIDColumn, + }, + fkColumn: performerIDColumn, + }, + movies: repository{ + tableName: moviesScenesTable, + idColumn: sceneIDColumn, + }, + files: filesRepository{ + repository: repository{ + tableName: scenesFilesTable, + idColumn: sceneIDColumn, + }, + }, + stashIDs: stashIDRepository{ + repository{ + tableName: "scene_stash_ids", + idColumn: sceneIDColumn, + }, + }, + } +) + +type SceneStore struct { blobJoinQueryBuilder tableMgr *table oDateManager viewDateManager - fileStore *FileStore + repo *storeRepository } -func NewSceneStore(fileStore *FileStore, blobStore *BlobStore) *SceneStore { +func NewSceneStore(r *storeRepository, blobStore *BlobStore) *SceneStore { return &SceneStore{ - repository: repository{ - tableName: sceneTable, - idColumn: idColumn, - }, blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, joinTable: sceneTable, @@ -193,7 +248,7 @@ func NewSceneStore(fileStore *FileStore, blobStore *BlobStore) *SceneStore { tableMgr: sceneTableMgr, viewDateManager: viewDateManager{scenesViewTableMgr}, oDateManager: oDateManager{scenesOTableMgr}, - fileStore: fileStore, + repo: r, } } @@ -531,13 +586,13 @@ func (qb *SceneStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*mo } func (qb *SceneStore) GetFiles(ctx context.Context, id int) ([]*models.VideoFile, error) { - fileIDs, err := qb.filesRepository().get(ctx, id) + fileIDs, err := sceneRepository.files.get(ctx, id) if err != nil { return nil, err } // use fileStore to load files - files, err := qb.fileStore.Find(ctx, fileIDs...) + files, err := qb.repo.File.Find(ctx, fileIDs...) if err != nil { return nil, err } @@ -556,7 +611,7 @@ func (qb *SceneStore) GetFiles(ctx context.Context, id int) ([]*models.VideoFile func (qb *SceneStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) { const primaryOnly = false - return qb.filesRepository().getMany(ctx, ids, primaryOnly) + return sceneRepository.files.getMany(ctx, ids, primaryOnly) } func (qb *SceneStore) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error) { @@ -864,176 +919,6 @@ func (qb *SceneStore) All(ctx context.Context) ([]*models.Scene, error) { )) } -func illegalFilterCombination(type1, type2 string) error { - return fmt.Errorf("cannot have %s and %s in the same filter", type1, type2) -} - -func (qb *SceneStore) validateFilter(sceneFilter *models.SceneFilterType) error { - const and = "AND" - const or = "OR" - const not = "NOT" - - if sceneFilter.And != nil { - if sceneFilter.Or != nil { - return illegalFilterCombination(and, or) - } - if sceneFilter.Not != nil { - return illegalFilterCombination(and, not) - } - - return qb.validateFilter(sceneFilter.And) - } - - if sceneFilter.Or != nil { - if sceneFilter.Not != nil { - return illegalFilterCombination(or, not) - } - - return qb.validateFilter(sceneFilter.Or) - } - - if sceneFilter.Not != nil { - return qb.validateFilter(sceneFilter.Not) - } - - return nil -} - -func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneFilterType) *filterBuilder { - query := &filterBuilder{} - - if sceneFilter.And != nil { - query.and(qb.makeFilter(ctx, sceneFilter.And)) - } - if sceneFilter.Or != nil { - query.or(qb.makeFilter(ctx, sceneFilter.Or)) - } - if sceneFilter.Not != nil { - query.not(qb.makeFilter(ctx, sceneFilter.Not)) - } - - query.handleCriterion(ctx, intCriterionHandler(sceneFilter.ID, "scenes.id", nil)) - query.handleCriterion(ctx, pathCriterionHandler(sceneFilter.Path, "folders.path", "files.basename", qb.addFoldersTable)) - query.handleCriterion(ctx, sceneFileCountCriterionHandler(qb, sceneFilter.FileCount)) - query.handleCriterion(ctx, stringCriterionHandler(sceneFilter.Title, "scenes.title")) - query.handleCriterion(ctx, stringCriterionHandler(sceneFilter.Code, "scenes.code")) - query.handleCriterion(ctx, stringCriterionHandler(sceneFilter.Details, "scenes.details")) - query.handleCriterion(ctx, stringCriterionHandler(sceneFilter.Director, "scenes.director")) - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if sceneFilter.Oshash != nil { - qb.addSceneFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_oshash", "scenes_files.file_id = fingerprints_oshash.file_id AND fingerprints_oshash.type = 'oshash'") - } - - stringCriterionHandler(sceneFilter.Oshash, "fingerprints_oshash.fingerprint")(ctx, f) - })) - - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if sceneFilter.Checksum != nil { - qb.addSceneFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_md5", "scenes_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") - } - - stringCriterionHandler(sceneFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) - })) - - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if sceneFilter.Phash != nil { - // backwards compatibility - scenePhashDistanceCriterionHandler(qb, &models.PhashDistanceCriterionInput{ - Value: sceneFilter.Phash.Value, - Modifier: sceneFilter.Phash.Modifier, - })(ctx, f) - } - })) - - query.handleCriterion(ctx, scenePhashDistanceCriterionHandler(qb, sceneFilter.PhashDistance)) - - query.handleCriterion(ctx, intCriterionHandler(sceneFilter.Rating100, "scenes.rating", nil)) - query.handleCriterion(ctx, sceneOCountCriterionHandler(sceneFilter.OCounter)) - query.handleCriterion(ctx, boolCriterionHandler(sceneFilter.Organized, "scenes.organized", nil)) - - query.handleCriterion(ctx, floatIntCriterionHandler(sceneFilter.Duration, "video_files.duration", qb.addVideoFilesTable)) - query.handleCriterion(ctx, resolutionCriterionHandler(sceneFilter.Resolution, "video_files.height", "video_files.width", qb.addVideoFilesTable)) - query.handleCriterion(ctx, orientationCriterionHandler(sceneFilter.Orientation, "video_files.height", "video_files.width", qb.addVideoFilesTable)) - query.handleCriterion(ctx, floatIntCriterionHandler(sceneFilter.Framerate, "ROUND(video_files.frame_rate)", qb.addVideoFilesTable)) - query.handleCriterion(ctx, intCriterionHandler(sceneFilter.Bitrate, "video_files.bit_rate", qb.addVideoFilesTable)) - query.handleCriterion(ctx, codecCriterionHandler(sceneFilter.VideoCodec, "video_files.video_codec", qb.addVideoFilesTable)) - query.handleCriterion(ctx, codecCriterionHandler(sceneFilter.AudioCodec, "video_files.audio_codec", qb.addVideoFilesTable)) - - query.handleCriterion(ctx, hasMarkersCriterionHandler(sceneFilter.HasMarkers)) - query.handleCriterion(ctx, sceneIsMissingCriterionHandler(qb, sceneFilter.IsMissing)) - query.handleCriterion(ctx, sceneURLsCriterionHandler(sceneFilter.URL)) - - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if sceneFilter.StashID != nil { - qb.stashIDRepository().join(f, "scene_stash_ids", "scenes.id") - stringCriterionHandler(sceneFilter.StashID, "scene_stash_ids.stash_id")(ctx, f) - } - })) - query.handleCriterion(ctx, &stashIDCriterionHandler{ - c: sceneFilter.StashIDEndpoint, - stashIDRepository: qb.stashIDRepository(), - stashIDTableAs: "scene_stash_ids", - parentIDCol: "scenes.id", - }) - - query.handleCriterion(ctx, boolCriterionHandler(sceneFilter.Interactive, "video_files.interactive", qb.addVideoFilesTable)) - query.handleCriterion(ctx, intCriterionHandler(sceneFilter.InteractiveSpeed, "video_files.interactive_speed", qb.addVideoFilesTable)) - - query.handleCriterion(ctx, sceneCaptionCriterionHandler(qb, sceneFilter.Captions)) - - query.handleCriterion(ctx, floatIntCriterionHandler(sceneFilter.ResumeTime, "scenes.resume_time", nil)) - query.handleCriterion(ctx, floatIntCriterionHandler(sceneFilter.PlayDuration, "scenes.play_duration", nil)) - query.handleCriterion(ctx, scenePlayCountCriterionHandler(sceneFilter.PlayCount)) - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if sceneFilter.LastPlayedAt != nil { - f.addLeftJoin( - fmt.Sprintf("(SELECT %s, MAX(%s) as last_played_at FROM %s GROUP BY %s)", sceneIDColumn, sceneViewDateColumn, scenesViewDatesTable, sceneIDColumn), - "scene_last_view", - fmt.Sprintf("scene_last_view.%s = scenes.id", sceneIDColumn), - ) - timestampCriterionHandler(sceneFilter.LastPlayedAt, "IFNULL(last_played_at, datetime(0))")(ctx, f) - } - })) - - query.handleCriterion(ctx, sceneTagsCriterionHandler(qb, sceneFilter.Tags)) - query.handleCriterion(ctx, sceneTagCountCriterionHandler(qb, sceneFilter.TagCount)) - query.handleCriterion(ctx, scenePerformersCriterionHandler(qb, sceneFilter.Performers)) - query.handleCriterion(ctx, scenePerformerCountCriterionHandler(qb, sceneFilter.PerformerCount)) - query.handleCriterion(ctx, studioCriterionHandler(sceneTable, sceneFilter.Studios)) - query.handleCriterion(ctx, sceneMoviesCriterionHandler(qb, sceneFilter.Movies)) - query.handleCriterion(ctx, sceneGalleriesCriterionHandler(qb, sceneFilter.Galleries)) - query.handleCriterion(ctx, scenePerformerTagsCriterionHandler(qb, sceneFilter.PerformerTags)) - query.handleCriterion(ctx, scenePerformerFavoriteCriterionHandler(sceneFilter.PerformerFavorite)) - query.handleCriterion(ctx, scenePerformerAgeCriterionHandler(sceneFilter.PerformerAge)) - query.handleCriterion(ctx, scenePhashDuplicatedCriterionHandler(sceneFilter.Duplicated, qb.addSceneFilesTable)) - query.handleCriterion(ctx, dateCriterionHandler(sceneFilter.Date, "scenes.date")) - query.handleCriterion(ctx, timestampCriterionHandler(sceneFilter.CreatedAt, "scenes.created_at")) - query.handleCriterion(ctx, timestampCriterionHandler(sceneFilter.UpdatedAt, "scenes.updated_at")) - - return query -} - -func (qb *SceneStore) addSceneFilesTable(f *filterBuilder) { - f.addLeftJoin(scenesFilesTable, "", "scenes_files.scene_id = scenes.id") -} - -func (qb *SceneStore) addFilesTable(f *filterBuilder) { - qb.addSceneFilesTable(f) - f.addLeftJoin(fileTable, "", "scenes_files.file_id = files.id") -} - -func (qb *SceneStore) addFoldersTable(f *filterBuilder) { - qb.addFilesTable(f) - f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") -} - -func (qb *SceneStore) addVideoFilesTable(f *filterBuilder) { - qb.addSceneFilesTable(f) - f.addLeftJoin(videoFileTable, "", "video_files.file_id = scenes_files.file_id") -} - func (qb *SceneStore) makeQuery(ctx context.Context, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if sceneFilter == nil { sceneFilter = &models.SceneFilterType{} @@ -1042,7 +927,7 @@ func (qb *SceneStore) makeQuery(ctx context.Context, sceneFilter *models.SceneFi findFilter = &models.FindFilterType{} } - query := qb.newQuery() + query := sceneRepository.newQuery() distinctIDs(&query, sceneTable) if q := findFilter.Q; q != nil && *q != "" { @@ -1074,10 +959,9 @@ func (qb *SceneStore) makeQuery(ctx context.Context, sceneFilter *models.SceneFi query.parseQueryString(searchColumns, *q) } - if err := qb.validateFilter(sceneFilter); err != nil { - return nil, err - } - filter := qb.makeFilter(ctx, sceneFilter) + filter := filterBuilderFromHandler(ctx, &sceneFilterHandler{ + sceneFilter: sceneFilter, + }) if err := query.addFilter(filter); err != nil { return nil, err @@ -1117,7 +1001,7 @@ func (qb *SceneStore) queryGroupedFields(ctx context.Context, options models.Sce return models.NewSceneQueryResult(qb), nil } - aggregateQuery := qb.newQuery() + aggregateQuery := sceneRepository.newQuery() if options.Count { aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total") @@ -1161,7 +1045,7 @@ func (qb *SceneStore) queryGroupedFields(ctx context.Context, options models.Sce Duration null.Float Size null.Float }{} - if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { + if err := sceneRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { return nil, err } @@ -1181,349 +1065,6 @@ func (qb *SceneStore) QueryCount(ctx context.Context, sceneFilter *models.SceneF return query.executeCount(ctx) } -func scenePlayCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: sceneTable, - joinTable: scenesViewDatesTable, - primaryFK: sceneIDColumn, - } - - return h.handler(count) -} - -func sceneOCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: sceneTable, - joinTable: scenesODatesTable, - primaryFK: sceneIDColumn, - } - - return h.handler(count) -} - -func sceneFileCountCriterionHandler(qb *SceneStore, fileCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: sceneTable, - joinTable: scenesFilesTable, - primaryFK: sceneIDColumn, - } - - return h.handler(fileCount) -} - -func scenePhashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - // TODO: Wishlist item: Implement Distance matching - if duplicatedFilter != nil { - if addJoinFn != nil { - addJoinFn(f) - } - - var v string - if *duplicatedFilter.Duplicated { - v = ">" - } else { - v = "=" - } - - f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "scenes_files.file_id = scph.file_id") - } - } -} - -func floatIntCriterionHandler(durationFilter *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if durationFilter != nil { - if addJoinFn != nil { - addJoinFn(f) - } - clause, args := getIntCriterionWhereClause("cast("+column+" as int)", *durationFilter) - f.addWhere(clause, args...) - } - } -} - -func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if resolution != nil && resolution.Value.IsValid() { - if addJoinFn != nil { - addJoinFn(f) - } - - min := resolution.Value.GetMinResolution() - max := resolution.Value.GetMaxResolution() - - widthHeight := fmt.Sprintf("MIN(%s, %s)", widthColumn, heightColumn) - - switch resolution.Modifier { - case models.CriterionModifierEquals: - f.addWhere(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, min, max)) - case models.CriterionModifierNotEquals: - f.addWhere(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, min, max)) - case models.CriterionModifierLessThan: - f.addWhere(fmt.Sprintf("%s < %d", widthHeight, min)) - case models.CriterionModifierGreaterThan: - f.addWhere(fmt.Sprintf("%s > %d", widthHeight, max)) - } - } - } -} - -func codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if codec != nil { - if addJoinFn != nil { - addJoinFn(f) - } - - stringCriterionHandler(codec, codecColumn)(ctx, f) - } - } -} - -func hasMarkersCriterionHandler(hasMarkers *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if hasMarkers != nil { - f.addLeftJoin("scene_markers", "", "scene_markers.scene_id = scenes.id") - if *hasMarkers == "true" { - f.addHaving("count(scene_markers.scene_id) > 0") - } else { - f.addWhere("scene_markers.id IS NULL") - } - } - } -} - -func sceneIsMissingCriterionHandler(qb *SceneStore, isMissing *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if isMissing != nil && *isMissing != "" { - switch *isMissing { - case "url": - scenesURLsTableMgr.join(f, "", "scenes.id") - f.addWhere("scene_urls.url IS NULL") - case "galleries": - qb.galleriesRepository().join(f, "galleries_join", "scenes.id") - f.addWhere("galleries_join.scene_id IS NULL") - case "studio": - f.addWhere("scenes.studio_id IS NULL") - case "movie": - qb.moviesRepository().join(f, "movies_join", "scenes.id") - f.addWhere("movies_join.scene_id IS NULL") - case "performers": - qb.performersRepository().join(f, "performers_join", "scenes.id") - f.addWhere("performers_join.scene_id IS NULL") - case "date": - f.addWhere(`scenes.date IS NULL OR scenes.date IS ""`) - case "tags": - qb.tagsRepository().join(f, "tags_join", "scenes.id") - f.addWhere("tags_join.scene_id IS NULL") - case "stash_id": - qb.stashIDRepository().join(f, "scene_stash_ids", "scenes.id") - f.addWhere("scene_stash_ids.scene_id IS NULL") - case "phash": - qb.addSceneFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") - f.addWhere("fingerprints_phash.fingerprint IS NULL") - case "cover": - f.addWhere("scenes.cover_blob IS NULL") - default: - f.addWhere("(scenes." + *isMissing + " IS NULL OR TRIM(scenes." + *isMissing + ") = '')") - } - } - } -} - -func sceneURLsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { - h := stringListCriterionHandlerBuilder{ - joinTable: scenesURLsTable, - stringColumn: sceneURLColumn, - addJoinTable: func(f *filterBuilder) { - scenesURLsTableMgr.join(f, "", "scenes.id") - }, - } - - return h.handler(url) -} - -func (qb *SceneStore) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { - return multiCriterionHandlerBuilder{ - primaryTable: sceneTable, - foreignTable: foreignTable, - joinTable: joinTable, - primaryFK: sceneIDColumn, - foreignFK: foreignFK, - addJoinsFunc: addJoinsFunc, - } -} - -func sceneCaptionCriterionHandler(qb *SceneStore, captions *models.StringCriterionInput) criterionHandlerFunc { - h := stringListCriterionHandlerBuilder{ - joinTable: videoCaptionsTable, - stringColumn: captionCodeColumn, - addJoinTable: func(f *filterBuilder) { - qb.addSceneFilesTable(f) - f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = scenes_files.file_id") - }, - } - - return h.handler(captions) -} - -func sceneTagsCriterionHandler(qb *SceneStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := joinedHierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: sceneTable, - foreignTable: tagTable, - foreignFK: "tag_id", - - relationsTable: "tags_relations", - joinAs: "scene_tag", - joinTable: scenesTagsTable, - primaryFK: sceneIDColumn, - } - - return h.handler(tags) -} - -func sceneTagCountCriterionHandler(qb *SceneStore, tagCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: sceneTable, - joinTable: scenesTagsTable, - primaryFK: sceneIDColumn, - } - - return h.handler(tagCount) -} - -func scenePerformersCriterionHandler(qb *SceneStore, performers *models.MultiCriterionInput) criterionHandlerFunc { - h := joinedMultiCriterionHandlerBuilder{ - primaryTable: sceneTable, - joinTable: performersScenesTable, - joinAs: "performers_join", - primaryFK: sceneIDColumn, - foreignFK: performerIDColumn, - - addJoinTable: func(f *filterBuilder) { - qb.performersRepository().join(f, "performers_join", "scenes.id") - }, - } - - return h.handler(performers) -} - -func scenePerformerCountCriterionHandler(qb *SceneStore, performerCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: sceneTable, - joinTable: performersScenesTable, - primaryFK: sceneIDColumn, - } - - return h.handler(performerCount) -} - -func scenePerformerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performerfavorite != nil { - f.addLeftJoin("performers_scenes", "", "scenes.id = performers_scenes.scene_id") - - if *performerfavorite { - // contains at least one favorite - f.addLeftJoin("performers", "", "performers.id = performers_scenes.performer_id") - f.addWhere("performers.favorite = 1") - } else { - // contains zero favorites - f.addLeftJoin(`(SELECT performers_scenes.scene_id as id FROM performers_scenes -JOIN performers ON performers.id = performers_scenes.performer_id -GROUP BY performers_scenes.scene_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "scenes.id = nofaves.id") - f.addWhere("performers_scenes.scene_id IS NULL OR nofaves.id IS NOT NULL") - } - } - } -} - -func scenePerformerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performerAge != nil { - f.addInnerJoin("performers_scenes", "", "scenes.id = performers_scenes.scene_id") - f.addInnerJoin("performers", "", "performers_scenes.performer_id = performers.id") - - f.addWhere("scenes.date != '' AND performers.birthdate != ''") - f.addWhere("scenes.date IS NOT NULL AND performers.birthdate IS NOT NULL") - - ageCalc := "cast(strftime('%Y.%m%d', scenes.date) - strftime('%Y.%m%d', performers.birthdate) as int)" - whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) - f.addWhere(whereClause, args...) - } - } -} - -func sceneMoviesCriterionHandler(qb *SceneStore, movies *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - qb.moviesRepository().join(f, "", "scenes.id") - f.addLeftJoin("movies", "", "movies_scenes.movie_id = movies.id") - } - h := qb.getMultiCriterionHandlerBuilder(movieTable, moviesScenesTable, "movie_id", addJoinsFunc) - return h.handler(movies) -} - -func sceneGalleriesCriterionHandler(qb *SceneStore, galleries *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - qb.galleriesRepository().join(f, "", "scenes.id") - f.addLeftJoin("galleries", "", "scenes_galleries.gallery_id = galleries.id") - } - h := qb.getMultiCriterionHandlerBuilder(galleryTable, scenesGalleriesTable, "gallery_id", addJoinsFunc) - return h.handler(galleries) -} - -func scenePerformerTagsCriterionHandler(qb *SceneStore, tags *models.HierarchicalMultiCriterionInput) criterionHandler { - return &joinedPerformerTagsHandler{ - criterion: tags, - primaryTable: sceneTable, - joinTable: performersScenesTable, - joinPrimaryKey: sceneIDColumn, - } -} - -func scenePhashDistanceCriterionHandler(qb *SceneStore, phashDistance *models.PhashDistanceCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if phashDistance != nil { - qb.addSceneFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") - - value, _ := utils.StringToPhash(phashDistance.Value) - distance := 0 - if phashDistance.Distance != nil { - distance = *phashDistance.Distance - } - - if distance == 0 { - // use the default handler - intCriterionHandler(&models.IntCriterionInput{ - Value: int(value), - Modifier: phashDistance.Modifier, - }, "fingerprints_phash.fingerprint", nil)(ctx, f) - } - - switch { - case phashDistance.Modifier == models.CriterionModifierEquals && distance > 0: - // needed to avoid a type mismatch - f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") - f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) < ?", value, distance) - case phashDistance.Modifier == models.CriterionModifierNotEquals && distance > 0: - // needed to avoid a type mismatch - f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") - f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) > ?", value, distance) - default: - intCriterionHandler(&models.IntCriterionInput{ - Value: int(value), - Modifier: phashDistance.Modifier, - }, "fingerprints_phash.fingerprint", nil)(ctx, f) - } - } - } -} - var sceneSortOptions = sortOptions{ "bitrate", "created_at", @@ -1719,7 +1260,7 @@ func (qb *SceneStore) AssignFiles(ctx context.Context, sceneID int, fileIDs []mo } // assign primary only if destination has no files - existingFileIDs, err := qb.filesRepository().get(ctx, sceneID) + existingFileIDs, err := sceneRepository.files.get(ctx, sceneID) if err != nil { return err } @@ -1728,18 +1269,10 @@ func (qb *SceneStore) AssignFiles(ctx context.Context, sceneID int, fileIDs []mo return scenesFilesTableMgr.insertJoins(ctx, sceneID, firstPrimary, fileIDs) } -func (qb *SceneStore) moviesRepository() *repository { - return &repository{ - tx: qb.tx, - tableName: moviesScenesTable, - idColumn: sceneIDColumn, - } -} - func (qb *SceneStore) GetMovies(ctx context.Context, id int) (ret []models.MoviesScenes, err error) { ret = []models.MoviesScenes{} - if err := qb.moviesRepository().getAll(ctx, id, func(rows *sqlx.Rows) error { + if err := sceneRepository.movies.getAll(ctx, id, func(rows *sqlx.Rows) error { var ms moviesScenesRow if err := rows.StructScan(&ms); err != nil { return err @@ -1754,91 +1287,36 @@ func (qb *SceneStore) GetMovies(ctx context.Context, id int) (ret []models.Movie return ret, nil } -func (qb *SceneStore) filesRepository() *filesRepository { - return &filesRepository{ - repository: repository{ - tx: qb.tx, - tableName: scenesFilesTable, - idColumn: sceneIDColumn, - }, - } -} - func (qb *SceneStore) AddFileID(ctx context.Context, id int, fileID models.FileID) error { const firstPrimary = false return scenesFilesTableMgr.insertJoins(ctx, id, firstPrimary, []models.FileID{fileID}) } -func (qb *SceneStore) performersRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: performersScenesTable, - idColumn: sceneIDColumn, - }, - fkColumn: performerIDColumn, - } -} - func (qb *SceneStore) GetPerformerIDs(ctx context.Context, id int) ([]int, error) { - return qb.performersRepository().getIDs(ctx, id) -} - -func (qb *SceneStore) tagsRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: scenesTagsTable, - idColumn: sceneIDColumn, - }, - fkColumn: tagIDColumn, - foreignTable: tagTable, - orderBy: "tags.name ASC", - } + return sceneRepository.performers.getIDs(ctx, id) } func (qb *SceneStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { - return qb.tagsRepository().getIDs(ctx, id) -} - -func (qb *SceneStore) galleriesRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: scenesGalleriesTable, - idColumn: sceneIDColumn, - }, - fkColumn: galleryIDColumn, - } + return sceneRepository.tags.getIDs(ctx, id) } func (qb *SceneStore) GetGalleryIDs(ctx context.Context, id int) ([]int, error) { - return qb.galleriesRepository().getIDs(ctx, id) + return sceneRepository.galleries.getIDs(ctx, id) } func (qb *SceneStore) AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error { return scenesGalleriesTableMgr.addJoins(ctx, sceneID, galleryIDs) } -func (qb *SceneStore) stashIDRepository() *stashIDRepository { - return &stashIDRepository{ - repository{ - tx: qb.tx, - tableName: "scene_stash_ids", - idColumn: sceneIDColumn, - }, - } -} - func (qb *SceneStore) GetStashIDs(ctx context.Context, sceneID int) ([]models.StashID, error) { - return qb.stashIDRepository().get(ctx, sceneID) + return sceneRepository.stashIDs.get(ctx, sceneID) } func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Scene, error) { var dupeIds [][]int if distance == 0 { var ids []string - if err := qb.tx.Select(ctx, &ids, findExactDuplicateQuery, durationDiff); err != nil { + if err := dbWrapper.Select(ctx, &ids, findExactDuplicateQuery, durationDiff); err != nil { return nil, err } @@ -1858,7 +1336,7 @@ func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int, duration } else { var hashes []*utils.Phash - if err := qb.queryFunc(ctx, findAllPhashesQuery, nil, false, func(rows *sqlx.Rows) error { + if err := sceneRepository.queryFunc(ctx, findAllPhashesQuery, nil, false, func(rows *sqlx.Rows) error { phash := utils.Phash{ Bucket: -1, Duration: -1, diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go new file mode 100644 index 000000000..2ce329a96 --- /dev/null +++ b/pkg/sqlite/scene_filter.go @@ -0,0 +1,533 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" +) + +type sceneFilterHandler struct { + sceneFilter *models.SceneFilterType +} + +func (qb *sceneFilterHandler) validate() error { + sceneFilter := qb.sceneFilter + if sceneFilter == nil { + return nil + } + + if err := validateFilterCombination(sceneFilter.OperatorFilter); err != nil { + return err + } + + if subFilter := sceneFilter.SubFilter(); subFilter != nil { + sqb := &sceneFilterHandler{sceneFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + return nil +} + +func (qb *sceneFilterHandler) handle(ctx context.Context, f *filterBuilder) { + sceneFilter := qb.sceneFilter + if sceneFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := sceneFilter.SubFilter() + if sf != nil { + sub := &sceneFilterHandler{sf} + handleSubFilter(ctx, sub, f, sceneFilter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *sceneFilterHandler) criterionHandler() criterionHandler { + sceneFilter := qb.sceneFilter + return compoundHandler{ + intCriterionHandler(sceneFilter.ID, "scenes.id", nil), + pathCriterionHandler(sceneFilter.Path, "folders.path", "files.basename", qb.addFoldersTable), + qb.fileCountCriterionHandler(sceneFilter.FileCount), + stringCriterionHandler(sceneFilter.Title, "scenes.title"), + stringCriterionHandler(sceneFilter.Code, "scenes.code"), + stringCriterionHandler(sceneFilter.Details, "scenes.details"), + stringCriterionHandler(sceneFilter.Director, "scenes.director"), + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if sceneFilter.Oshash != nil { + qb.addSceneFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_oshash", "scenes_files.file_id = fingerprints_oshash.file_id AND fingerprints_oshash.type = 'oshash'") + } + + stringCriterionHandler(sceneFilter.Oshash, "fingerprints_oshash.fingerprint")(ctx, f) + }), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if sceneFilter.Checksum != nil { + qb.addSceneFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_md5", "scenes_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") + } + + stringCriterionHandler(sceneFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) + }), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if sceneFilter.Phash != nil { + // backwards compatibility + qb.phashDistanceCriterionHandler(&models.PhashDistanceCriterionInput{ + Value: sceneFilter.Phash.Value, + Modifier: sceneFilter.Phash.Modifier, + })(ctx, f) + } + }), + + qb.phashDistanceCriterionHandler(sceneFilter.PhashDistance), + + intCriterionHandler(sceneFilter.Rating100, "scenes.rating", nil), + qb.oCountCriterionHandler(sceneFilter.OCounter), + boolCriterionHandler(sceneFilter.Organized, "scenes.organized", nil), + + floatIntCriterionHandler(sceneFilter.Duration, "video_files.duration", qb.addVideoFilesTable), + resolutionCriterionHandler(sceneFilter.Resolution, "video_files.height", "video_files.width", qb.addVideoFilesTable), + orientationCriterionHandler(sceneFilter.Orientation, "video_files.height", "video_files.width", qb.addVideoFilesTable), + floatIntCriterionHandler(sceneFilter.Framerate, "ROUND(video_files.frame_rate)", qb.addVideoFilesTable), + intCriterionHandler(sceneFilter.Bitrate, "video_files.bit_rate", qb.addVideoFilesTable), + qb.codecCriterionHandler(sceneFilter.VideoCodec, "video_files.video_codec", qb.addVideoFilesTable), + qb.codecCriterionHandler(sceneFilter.AudioCodec, "video_files.audio_codec", qb.addVideoFilesTable), + + qb.hasMarkersCriterionHandler(sceneFilter.HasMarkers), + qb.isMissingCriterionHandler(sceneFilter.IsMissing), + qb.urlsCriterionHandler(sceneFilter.URL), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if sceneFilter.StashID != nil { + sceneRepository.stashIDs.join(f, "scene_stash_ids", "scenes.id") + stringCriterionHandler(sceneFilter.StashID, "scene_stash_ids.stash_id")(ctx, f) + } + }), + + &stashIDCriterionHandler{ + c: sceneFilter.StashIDEndpoint, + stashIDRepository: &sceneRepository.stashIDs, + stashIDTableAs: "scene_stash_ids", + parentIDCol: "scenes.id", + }, + + boolCriterionHandler(sceneFilter.Interactive, "video_files.interactive", qb.addVideoFilesTable), + intCriterionHandler(sceneFilter.InteractiveSpeed, "video_files.interactive_speed", qb.addVideoFilesTable), + + qb.captionCriterionHandler(sceneFilter.Captions), + + floatIntCriterionHandler(sceneFilter.ResumeTime, "scenes.resume_time", nil), + floatIntCriterionHandler(sceneFilter.PlayDuration, "scenes.play_duration", nil), + qb.playCountCriterionHandler(sceneFilter.PlayCount), + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if sceneFilter.LastPlayedAt != nil { + f.addLeftJoin( + fmt.Sprintf("(SELECT %s, MAX(%s) as last_played_at FROM %s GROUP BY %s)", sceneIDColumn, sceneViewDateColumn, scenesViewDatesTable, sceneIDColumn), + "scene_last_view", + fmt.Sprintf("scene_last_view.%s = scenes.id", sceneIDColumn), + ) + h := timestampCriterionHandler{sceneFilter.LastPlayedAt, "IFNULL(last_played_at, datetime(0))", nil} + h.handle(ctx, f) + } + }), + + qb.tagsCriterionHandler(sceneFilter.Tags), + qb.tagCountCriterionHandler(sceneFilter.TagCount), + qb.performersCriterionHandler(sceneFilter.Performers), + qb.performerCountCriterionHandler(sceneFilter.PerformerCount), + studioCriterionHandler(sceneTable, sceneFilter.Studios), + qb.moviesCriterionHandler(sceneFilter.Movies), + qb.galleriesCriterionHandler(sceneFilter.Galleries), + qb.performerTagsCriterionHandler(sceneFilter.PerformerTags), + qb.performerFavoriteCriterionHandler(sceneFilter.PerformerFavorite), + qb.performerAgeCriterionHandler(sceneFilter.PerformerAge), + qb.phashDuplicatedCriterionHandler(sceneFilter.Duplicated, qb.addSceneFilesTable), + &dateCriterionHandler{sceneFilter.Date, "scenes.date", nil}, + ×tampCriterionHandler{sceneFilter.CreatedAt, "scenes.created_at", nil}, + ×tampCriterionHandler{sceneFilter.UpdatedAt, "scenes.updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "scenes_galleries.gallery_id", + relatedRepo: galleryRepository.repository, + relatedHandler: &galleryFilterHandler{sceneFilter.GalleriesFilter}, + joinFn: func(f *filterBuilder) { + sceneRepository.galleries.innerJoin(f, "", "scenes.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "performers_join.performer_id", + relatedRepo: performerRepository.repository, + relatedHandler: &performerFilterHandler{sceneFilter.PerformersFilter}, + joinFn: func(f *filterBuilder) { + sceneRepository.performers.innerJoin(f, "performers_join", "scenes.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "scenes.studio_id", + relatedRepo: studioRepository.repository, + relatedHandler: &studioFilterHandler{sceneFilter.StudiosFilter}, + }, + + &relatedFilterHandler{ + relatedIDCol: "scene_tag.tag_id", + relatedRepo: tagRepository.repository, + relatedHandler: &tagFilterHandler{sceneFilter.TagsFilter}, + joinFn: func(f *filterBuilder) { + sceneRepository.tags.innerJoin(f, "scene_tag", "scenes.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "movies_scenes.movie_id", + relatedRepo: movieRepository.repository, + relatedHandler: &movieFilterHandler{sceneFilter.MoviesFilter}, + joinFn: func(f *filterBuilder) { + sceneRepository.movies.innerJoin(f, "", "scenes.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "scene_markers.id", + relatedRepo: sceneMarkerRepository.repository, + relatedHandler: &sceneMarkerFilterHandler{sceneFilter.MarkersFilter}, + joinFn: func(f *filterBuilder) { + f.addInnerJoin("scene_markers", "", "scenes.id") + }, + }, + } +} + +func (qb *sceneFilterHandler) addSceneFilesTable(f *filterBuilder) { + f.addLeftJoin(scenesFilesTable, "", "scenes_files.scene_id = scenes.id") +} + +func (qb *sceneFilterHandler) addFilesTable(f *filterBuilder) { + qb.addSceneFilesTable(f) + f.addLeftJoin(fileTable, "", "scenes_files.file_id = files.id") +} + +func (qb *sceneFilterHandler) addFoldersTable(f *filterBuilder) { + qb.addFilesTable(f) + f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") +} + +func (qb *sceneFilterHandler) addVideoFilesTable(f *filterBuilder) { + qb.addSceneFilesTable(f) + f.addLeftJoin(videoFileTable, "", "video_files.file_id = scenes_files.file_id") +} + +func (qb *sceneFilterHandler) playCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: scenesViewDatesTable, + primaryFK: sceneIDColumn, + } + + return h.handler(count) +} + +func (qb *sceneFilterHandler) oCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: scenesODatesTable, + primaryFK: sceneIDColumn, + } + + return h.handler(count) +} + +func (qb *sceneFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: scenesFilesTable, + primaryFK: sceneIDColumn, + } + + return h.handler(fileCount) +} + +func (qb *sceneFilterHandler) phashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + // TODO: Wishlist item: Implement Distance matching + if duplicatedFilter != nil { + if addJoinFn != nil { + addJoinFn(f) + } + + var v string + if *duplicatedFilter.Duplicated { + v = ">" + } else { + v = "=" + } + + f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "scenes_files.file_id = scph.file_id") + } + } +} + +func (qb *sceneFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if codec != nil { + if addJoinFn != nil { + addJoinFn(f) + } + + stringCriterionHandler(codec, codecColumn)(ctx, f) + } + } +} + +func (qb *sceneFilterHandler) hasMarkersCriterionHandler(hasMarkers *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if hasMarkers != nil { + f.addLeftJoin("scene_markers", "", "scene_markers.scene_id = scenes.id") + if *hasMarkers == "true" { + f.addHaving("count(scene_markers.scene_id) > 0") + } else { + f.addWhere("scene_markers.id IS NULL") + } + } + } +} + +func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "url": + scenesURLsTableMgr.join(f, "", "scenes.id") + f.addWhere("scene_urls.url IS NULL") + case "galleries": + sceneRepository.galleries.join(f, "galleries_join", "scenes.id") + f.addWhere("galleries_join.scene_id IS NULL") + case "studio": + f.addWhere("scenes.studio_id IS NULL") + case "movie": + sceneRepository.movies.join(f, "movies_join", "scenes.id") + f.addWhere("movies_join.scene_id IS NULL") + case "performers": + sceneRepository.performers.join(f, "performers_join", "scenes.id") + f.addWhere("performers_join.scene_id IS NULL") + case "date": + f.addWhere(`scenes.date IS NULL OR scenes.date IS ""`) + case "tags": + sceneRepository.tags.join(f, "tags_join", "scenes.id") + f.addWhere("tags_join.scene_id IS NULL") + case "stash_id": + sceneRepository.stashIDs.join(f, "scene_stash_ids", "scenes.id") + f.addWhere("scene_stash_ids.scene_id IS NULL") + case "phash": + qb.addSceneFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") + f.addWhere("fingerprints_phash.fingerprint IS NULL") + case "cover": + f.addWhere("scenes.cover_blob IS NULL") + default: + f.addWhere("(scenes." + *isMissing + " IS NULL OR TRIM(scenes." + *isMissing + ") = '')") + } + } + } +} + +func (qb *sceneFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: scenesURLsTable, + stringColumn: sceneURLColumn, + addJoinTable: func(f *filterBuilder) { + scenesURLsTableMgr.join(f, "", "scenes.id") + }, + } + + return h.handler(url) +} + +func (qb *sceneFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { + return multiCriterionHandlerBuilder{ + primaryTable: sceneTable, + foreignTable: foreignTable, + joinTable: joinTable, + primaryFK: sceneIDColumn, + foreignFK: foreignFK, + addJoinsFunc: addJoinsFunc, + } +} + +func (qb *sceneFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: videoCaptionsTable, + stringColumn: captionCodeColumn, + addJoinTable: func(f *filterBuilder) { + qb.addSceneFilesTable(f) + f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = scenes_files.file_id") + }, + } + + return h.handler(captions) +} + +func (qb *sceneFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: sceneTable, + foreignTable: tagTable, + foreignFK: "tag_id", + + relationsTable: "tags_relations", + joinAs: "scene_tag", + joinTable: scenesTagsTable, + primaryFK: sceneIDColumn, + } + + return h.handler(tags) +} + +func (qb *sceneFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: scenesTagsTable, + primaryFK: sceneIDColumn, + } + + return h.handler(tagCount) +} + +func (qb *sceneFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { + h := joinedMultiCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: performersScenesTable, + joinAs: "performers_join", + primaryFK: sceneIDColumn, + foreignFK: performerIDColumn, + + addJoinTable: func(f *filterBuilder) { + sceneRepository.performers.join(f, "performers_join", "scenes.id") + }, + } + + return h.handler(performers) +} + +func (qb *sceneFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: performersScenesTable, + primaryFK: sceneIDColumn, + } + + return h.handler(performerCount) +} + +func (qb *sceneFilterHandler) performerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerfavorite != nil { + f.addLeftJoin("performers_scenes", "", "scenes.id = performers_scenes.scene_id") + + if *performerfavorite { + // contains at least one favorite + f.addLeftJoin("performers", "", "performers.id = performers_scenes.performer_id") + f.addWhere("performers.favorite = 1") + } else { + // contains zero favorites + f.addLeftJoin(`(SELECT performers_scenes.scene_id as id FROM performers_scenes +JOIN performers ON performers.id = performers_scenes.performer_id +GROUP BY performers_scenes.scene_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "scenes.id = nofaves.id") + f.addWhere("performers_scenes.scene_id IS NULL OR nofaves.id IS NOT NULL") + } + } + } +} + +func (qb *sceneFilterHandler) performerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerAge != nil { + f.addInnerJoin("performers_scenes", "", "scenes.id = performers_scenes.scene_id") + f.addInnerJoin("performers", "", "performers_scenes.performer_id = performers.id") + + f.addWhere("scenes.date != '' AND performers.birthdate != ''") + f.addWhere("scenes.date IS NOT NULL AND performers.birthdate IS NOT NULL") + + ageCalc := "cast(strftime('%Y.%m%d', scenes.date) - strftime('%Y.%m%d', performers.birthdate) as int)" + whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) + f.addWhere(whereClause, args...) + } + } +} + +func (qb *sceneFilterHandler) moviesCriterionHandler(movies *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + sceneRepository.movies.join(f, "", "scenes.id") + f.addLeftJoin("movies", "", "movies_scenes.movie_id = movies.id") + } + h := qb.getMultiCriterionHandlerBuilder(movieTable, moviesScenesTable, "movie_id", addJoinsFunc) + return h.handler(movies) +} + +func (qb *sceneFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + sceneRepository.galleries.join(f, "", "scenes.id") + f.addLeftJoin("galleries", "", "scenes_galleries.gallery_id = galleries.id") + } + h := qb.getMultiCriterionHandlerBuilder(galleryTable, scenesGalleriesTable, "gallery_id", addJoinsFunc) + return h.handler(galleries) +} + +func (qb *sceneFilterHandler) performerTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandler { + return &joinedPerformerTagsHandler{ + criterion: tags, + primaryTable: sceneTable, + joinTable: performersScenesTable, + joinPrimaryKey: sceneIDColumn, + } +} + +func (qb *sceneFilterHandler) phashDistanceCriterionHandler(phashDistance *models.PhashDistanceCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if phashDistance != nil { + qb.addSceneFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") + + value, _ := utils.StringToPhash(phashDistance.Value) + distance := 0 + if phashDistance.Distance != nil { + distance = *phashDistance.Distance + } + + if distance == 0 { + // use the default handler + intCriterionHandler(&models.IntCriterionInput{ + Value: int(value), + Modifier: phashDistance.Modifier, + }, "fingerprints_phash.fingerprint", nil)(ctx, f) + } + + switch { + case phashDistance.Modifier == models.CriterionModifierEquals && distance > 0: + // needed to avoid a type mismatch + f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") + f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) < ?", value, distance) + case phashDistance.Modifier == models.CriterionModifierNotEquals && distance > 0: + // needed to avoid a type mismatch + f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") + f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) > ?", value, distance) + default: + intCriterionHandler(&models.IntCriterionInput{ + Value: int(value), + Modifier: phashDistance.Modifier, + }, "fingerprints_phash.fingerprint", nil)(ctx, f) + } + } + } +} diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index f1221cd0e..158916a82 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -75,24 +75,41 @@ func (r *sceneMarkerRowRecord) fromPartial(o models.SceneMarkerPartial) { r.setTimestamp("updated_at", o.UpdatedAt) } -type SceneMarkerStore struct { +type sceneMarkerRepositoryType struct { repository - tableMgr *table + scenes repository + tags joinRepository } -func NewSceneMarkerStore() *SceneMarkerStore { - return &SceneMarkerStore{ +var ( + sceneMarkerRepository = sceneMarkerRepositoryType{ repository: repository{ tableName: sceneMarkerTable, idColumn: idColumn, }, - tableMgr: sceneMarkerTableMgr, + scenes: repository{ + tableName: sceneTable, + idColumn: idColumn, + }, + tags: joinRepository{ + repository: repository{ + tableName: "scene_markers_tags", + idColumn: "scene_marker_id", + }, + fkColumn: tagIDColumn, + }, } +) + +type SceneMarkerStore struct{} + +func NewSceneMarkerStore() *SceneMarkerStore { + return &SceneMarkerStore{} } func (qb *SceneMarkerStore) table() exp.IdentifierExpression { - return qb.tableMgr.table + return sceneMarkerTableMgr.table } func (qb *SceneMarkerStore) selectDataset() *goqu.SelectDataset { @@ -103,7 +120,7 @@ func (qb *SceneMarkerStore) Create(ctx context.Context, newObject *models.SceneM var r sceneMarkerRow r.fromSceneMarker(*newObject) - id, err := qb.tableMgr.insertID(ctx, r) + id, err := sceneMarkerTableMgr.insertID(ctx, r) if err != nil { return err } @@ -128,7 +145,7 @@ func (qb *SceneMarkerStore) UpdatePartial(ctx context.Context, id int, partial m r.fromPartial(partial) if len(r.Record) > 0 { - if err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil { + if err := sceneMarkerTableMgr.updateByID(ctx, id, r.Record); err != nil { return nil, err } } @@ -140,7 +157,7 @@ func (qb *SceneMarkerStore) Update(ctx context.Context, updatedObject *models.Sc var r sceneMarkerRow r.fromSceneMarker(*updatedObject) - if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { + if err := sceneMarkerTableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err } @@ -148,7 +165,7 @@ func (qb *SceneMarkerStore) Update(ctx context.Context, updatedObject *models.Sc } func (qb *SceneMarkerStore) Destroy(ctx context.Context, id int) error { - return qb.destroyExisting(ctx, []int{id}) + return sceneMarkerRepository.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found @@ -186,7 +203,7 @@ func (qb *SceneMarkerStore) FindMany(ctx context.Context, ids []int) ([]*models. // returns nil, sql.ErrNoRows if not found func (qb *SceneMarkerStore) find(ctx context.Context, id int) (*models.SceneMarker, error) { - q := qb.selectDataset().Where(qb.tableMgr.byID(id)) + q := qb.selectDataset().Where(sceneMarkerTableMgr.byID(id)) ret, err := qb.get(ctx, q) if err != nil { @@ -243,7 +260,7 @@ func (qb *SceneMarkerStore) FindBySceneID(ctx context.Context, sceneID int) ([]* func (qb *SceneMarkerStore) CountByTagID(ctx context.Context, tagID int) (int, error) { args := []interface{}{tagID, tagID} - return qb.runCountQuery(ctx, qb.buildCountQuery(countSceneMarkersForTagQuery), args) + return sceneMarkerRepository.runCountQuery(ctx, sceneMarkerRepository.buildCountQuery(countSceneMarkersForTagQuery), args) } func (qb *SceneMarkerStore) GetMarkerStrings(ctx context.Context, q *string, sort *string) ([]*models.MarkerStringsResultType, error) { @@ -272,21 +289,6 @@ func (qb *SceneMarkerStore) Wall(ctx context.Context, q *string) ([]*models.Scen return qb.getMany(ctx, qq) } -func (qb *SceneMarkerStore) makeFilter(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType) *filterBuilder { - query := &filterBuilder{} - - query.handleCriterion(ctx, sceneMarkerTagIDCriterionHandler(qb, sceneMarkerFilter.TagID)) - query.handleCriterion(ctx, sceneMarkerTagsCriterionHandler(qb, sceneMarkerFilter.Tags)) - query.handleCriterion(ctx, sceneMarkerSceneTagsCriterionHandler(qb, sceneMarkerFilter.SceneTags)) - query.handleCriterion(ctx, sceneMarkerPerformersCriterionHandler(qb, sceneMarkerFilter.Performers)) - query.handleCriterion(ctx, timestampCriterionHandler(sceneMarkerFilter.CreatedAt, "scene_markers.created_at")) - query.handleCriterion(ctx, timestampCriterionHandler(sceneMarkerFilter.UpdatedAt, "scene_markers.updated_at")) - query.handleCriterion(ctx, dateCriterionHandler(sceneMarkerFilter.SceneDate, "scenes.date")) - query.handleCriterion(ctx, timestampCriterionHandler(sceneMarkerFilter.SceneCreatedAt, "scenes.created_at")) - query.handleCriterion(ctx, timestampCriterionHandler(sceneMarkerFilter.SceneUpdatedAt, "scenes.updated_at")) - - return query -} func (qb *SceneMarkerStore) makeQuery(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if sceneMarkerFilter == nil { sceneMarkerFilter = &models.SceneMarkerFilterType{} @@ -295,7 +297,7 @@ func (qb *SceneMarkerStore) makeQuery(ctx context.Context, sceneMarkerFilter *mo findFilter = &models.FindFilterType{} } - query := qb.newQuery() + query := sceneMarkerRepository.newQuery() distinctIDs(&query, sceneMarkerTable) if q := findFilter.Q; q != nil && *q != "" { @@ -304,7 +306,9 @@ func (qb *SceneMarkerStore) makeQuery(ctx context.Context, sceneMarkerFilter *mo query.parseQueryString(searchColumns, *q) } - filter := qb.makeFilter(ctx, sceneMarkerFilter) + filter := filterBuilderFromHandler(ctx, &sceneMarkerFilterHandler{ + sceneMarkerFilter: sceneMarkerFilter, + }) if err := query.addFilter(filter); err != nil { return nil, err @@ -346,135 +350,6 @@ func (qb *SceneMarkerStore) QueryCount(ctx context.Context, sceneMarkerFilter *m return query.executeCount(ctx) } -func sceneMarkerTagIDCriterionHandler(qb *SceneMarkerStore, tagID *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if tagID != nil { - f.addLeftJoin("scene_markers_tags", "", "scene_markers_tags.scene_marker_id = scene_markers.id") - - f.addWhere("(scene_markers.primary_tag_id = ? OR scene_markers_tags.tag_id = ?)", *tagID, *tagID) - } - } -} - -func sceneMarkerTagsCriterionHandler(qb *SceneMarkerStore, criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { - tags := criterion.CombineExcludes() - - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("scene_markers_tags", "", "scene_markers.id = scene_markers_tags.scene_marker_id") - - f.addWhere(fmt.Sprintf("%s scene_markers_tags.tag_id IS NULL", notClause)) - return - } - - if tags.Modifier == models.CriterionModifierEquals && tags.Depth != nil && *tags.Depth != 0 { - f.setError(fmt.Errorf("depth is not supported for equals modifier for marker tag filtering")) - return - } - - if len(tags.Value) == 0 && len(tags.Excludes) == 0 { - return - } - - if len(tags.Value) > 0 { - valuesClause, err := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "parent_id", "child_id", tags.Depth) - if err != nil { - f.setError(err) - return - } - - f.addWith(`marker_tags AS ( - SELECT mt.scene_marker_id, t.column1 AS root_tag_id FROM scene_markers_tags mt - INNER JOIN (` + valuesClause + `) t ON t.column2 = mt.tag_id - UNION - SELECT m.id, t.column1 FROM scene_markers m - INNER JOIN (` + valuesClause + `) t ON t.column2 = m.primary_tag_id - )`) - - f.addLeftJoin("marker_tags", "", "marker_tags.scene_marker_id = scene_markers.id") - - switch tags.Modifier { - case models.CriterionModifierEquals: - // includes only the provided ids - f.addWhere("marker_tags.root_tag_id IS NOT NULL") - tagsLen := len(tags.Value) - f.addHaving(fmt.Sprintf("count(distinct marker_tags.root_tag_id) IS %d", tagsLen)) - // decrement by one to account for primary tag id - f.addWhere("(SELECT COUNT(*) FROM scene_markers_tags s WHERE s.scene_marker_id = scene_markers.id) = ?", tagsLen-1) - case models.CriterionModifierNotEquals: - f.setError(fmt.Errorf("not equals modifier is not supported for scene marker tags")) - default: - addHierarchicalConditionClauses(f, tags, "marker_tags", "root_tag_id") - } - } - - if len(criterion.Excludes) > 0 { - valuesClause, err := getHierarchicalValues(ctx, dbWrapper{}, tags.Excludes, tagTable, "tags_relations", "parent_id", "child_id", tags.Depth) - if err != nil { - f.setError(err) - return - } - - clause := "scene_markers.id NOT IN (SELECT scene_markers_tags.scene_marker_id FROM scene_markers_tags WHERE scene_markers_tags.tag_id IN (SELECT column2 FROM (%s)))" - f.addWhere(fmt.Sprintf(clause, valuesClause)) - - f.addWhere(fmt.Sprintf("scene_markers.primary_tag_id NOT IN (SELECT column2 FROM (%s))", valuesClause)) - } - } - } -} - -func sceneMarkerSceneTagsCriterionHandler(qb *SceneMarkerStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if tags != nil { - f.addLeftJoin("scenes_tags", "", "scene_markers.scene_id = scenes_tags.scene_id") - - h := joinedHierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: "scene_markers", - primaryKey: sceneIDColumn, - foreignTable: tagTable, - foreignFK: tagIDColumn, - - relationsTable: "tags_relations", - joinTable: "scenes_tags", - joinAs: "marker_scenes_tags", - primaryFK: sceneIDColumn, - } - - h.handler(tags).handle(ctx, f) - } - } -} - -func sceneMarkerPerformersCriterionHandler(qb *SceneMarkerStore, performers *models.MultiCriterionInput) criterionHandlerFunc { - h := joinedMultiCriterionHandlerBuilder{ - primaryTable: sceneTable, - joinTable: performersScenesTable, - joinAs: "performers_join", - primaryFK: sceneIDColumn, - foreignFK: performerIDColumn, - - addJoinTable: func(f *filterBuilder) { - f.addLeftJoin(performersScenesTable, "performers_join", "performers_join.scene_id = scene_markers.scene_id") - }, - } - - handler := h.handler(performers) - return func(ctx context.Context, f *filterBuilder) { - // Make sure scenes is included, otherwise excludes filter fails - f.addLeftJoin(sceneTable, "", "scenes.id = scene_markers.scene_id") - handler(ctx, f) - } -} - var sceneMarkerSortOptions = sortOptions{ "created_at", "id", @@ -514,7 +389,7 @@ func (qb *SceneMarkerStore) setSceneMarkerSort(query *queryBuilder, findFilter * func (qb *SceneMarkerStore) querySceneMarkers(ctx context.Context, query string, args []interface{}) ([]*models.SceneMarker, error) { const single = false var ret []*models.SceneMarker - if err := qb.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { + if err := sceneMarkerRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { var f sceneMarkerRow if err := r.StructScan(&f); err != nil { return err @@ -532,7 +407,7 @@ func (qb *SceneMarkerStore) querySceneMarkers(ctx context.Context, query string, } func (qb *SceneMarkerStore) queryMarkerStringsResultType(ctx context.Context, query string, args []interface{}) ([]*models.MarkerStringsResultType, error) { - rows, err := qb.tx.Queryx(ctx, query, args...) + rows, err := dbWrapper.Queryx(ctx, query, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err } @@ -554,24 +429,13 @@ func (qb *SceneMarkerStore) queryMarkerStringsResultType(ctx context.Context, qu return markerStrings, nil } -func (qb *SceneMarkerStore) tagsRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: "scene_markers_tags", - idColumn: "scene_marker_id", - }, - fkColumn: tagIDColumn, - } -} - func (qb *SceneMarkerStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { - return qb.tagsRepository().getIDs(ctx, id) + return sceneMarkerRepository.tags.getIDs(ctx, id) } func (qb *SceneMarkerStore) UpdateTags(ctx context.Context, id int, tagIDs []int) error { // Delete the existing joins and then create new ones - return qb.tagsRepository().replace(ctx, id, tagIDs) + return sceneMarkerRepository.tags.replace(ctx, id, tagIDs) } func (qb *SceneMarkerStore) Count(ctx context.Context) (int, error) { diff --git a/pkg/sqlite/scene_marker_filter.go b/pkg/sqlite/scene_marker_filter.go new file mode 100644 index 000000000..94147ed80 --- /dev/null +++ b/pkg/sqlite/scene_marker_filter.go @@ -0,0 +1,189 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +type sceneMarkerFilterHandler struct { + sceneMarkerFilter *models.SceneMarkerFilterType +} + +func (qb *sceneMarkerFilterHandler) validate() error { + return nil +} + +func (qb *sceneMarkerFilterHandler) handle(ctx context.Context, f *filterBuilder) { + sceneMarkerFilter := qb.sceneMarkerFilter + if sceneMarkerFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *sceneMarkerFilterHandler) joinScenes(f *filterBuilder) { + sceneMarkerRepository.scenes.innerJoin(f, "", "scene_markers.scene_id") +} + +func (qb *sceneMarkerFilterHandler) criterionHandler() criterionHandler { + sceneMarkerFilter := qb.sceneMarkerFilter + return compoundHandler{ + qb.tagIDCriterionHandler(sceneMarkerFilter.TagID), + qb.tagsCriterionHandler(sceneMarkerFilter.Tags), + qb.sceneTagsCriterionHandler(sceneMarkerFilter.SceneTags), + qb.performersCriterionHandler(sceneMarkerFilter.Performers), + ×tampCriterionHandler{sceneMarkerFilter.CreatedAt, "scene_markers.created_at", nil}, + ×tampCriterionHandler{sceneMarkerFilter.UpdatedAt, "scene_markers.updated_at", nil}, + &dateCriterionHandler{sceneMarkerFilter.SceneDate, "scenes.date", qb.joinScenes}, + ×tampCriterionHandler{sceneMarkerFilter.SceneCreatedAt, "scenes.created_at", qb.joinScenes}, + ×tampCriterionHandler{sceneMarkerFilter.SceneUpdatedAt, "scenes.updated_at", qb.joinScenes}, + + &relatedFilterHandler{ + relatedIDCol: "scenes.id", + relatedRepo: sceneRepository.repository, + relatedHandler: &sceneFilterHandler{sceneMarkerFilter.SceneFilter}, + joinFn: func(f *filterBuilder) { + qb.joinScenes(f) + }, + }, + } +} + +func (qb *sceneMarkerFilterHandler) tagIDCriterionHandler(tagID *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if tagID != nil { + f.addLeftJoin("scene_markers_tags", "", "scene_markers_tags.scene_marker_id = scene_markers.id") + + f.addWhere("(scene_markers.primary_tag_id = ? OR scene_markers_tags.tag_id = ?)", *tagID, *tagID) + } + } +} + +func (qb *sceneMarkerFilterHandler) tagsCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + tags := criterion.CombineExcludes() + + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { + var notClause string + if tags.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addLeftJoin("scene_markers_tags", "", "scene_markers.id = scene_markers_tags.scene_marker_id") + + f.addWhere(fmt.Sprintf("%s scene_markers_tags.tag_id IS NULL", notClause)) + return + } + + if tags.Modifier == models.CriterionModifierEquals && tags.Depth != nil && *tags.Depth != 0 { + f.setError(fmt.Errorf("depth is not supported for equals modifier for marker tag filtering")) + return + } + + if len(tags.Value) == 0 && len(tags.Excludes) == 0 { + return + } + + if len(tags.Value) > 0 { + valuesClause, err := getHierarchicalValues(ctx, tags.Value, tagTable, "tags_relations", "parent_id", "child_id", tags.Depth) + if err != nil { + f.setError(err) + return + } + + f.addWith(`marker_tags AS ( + SELECT mt.scene_marker_id, t.column1 AS root_tag_id FROM scene_markers_tags mt + INNER JOIN (` + valuesClause + `) t ON t.column2 = mt.tag_id + UNION + SELECT m.id, t.column1 FROM scene_markers m + INNER JOIN (` + valuesClause + `) t ON t.column2 = m.primary_tag_id + )`) + + f.addLeftJoin("marker_tags", "", "marker_tags.scene_marker_id = scene_markers.id") + + switch tags.Modifier { + case models.CriterionModifierEquals: + // includes only the provided ids + f.addWhere("marker_tags.root_tag_id IS NOT NULL") + tagsLen := len(tags.Value) + f.addHaving(fmt.Sprintf("count(distinct marker_tags.root_tag_id) IS %d", tagsLen)) + // decrement by one to account for primary tag id + f.addWhere("(SELECT COUNT(*) FROM scene_markers_tags s WHERE s.scene_marker_id = scene_markers.id) = ?", tagsLen-1) + case models.CriterionModifierNotEquals: + f.setError(fmt.Errorf("not equals modifier is not supported for scene marker tags")) + default: + addHierarchicalConditionClauses(f, tags, "marker_tags", "root_tag_id") + } + } + + if len(criterion.Excludes) > 0 { + valuesClause, err := getHierarchicalValues(ctx, tags.Excludes, tagTable, "tags_relations", "parent_id", "child_id", tags.Depth) + if err != nil { + f.setError(err) + return + } + + clause := "scene_markers.id NOT IN (SELECT scene_markers_tags.scene_marker_id FROM scene_markers_tags WHERE scene_markers_tags.tag_id IN (SELECT column2 FROM (%s)))" + f.addWhere(fmt.Sprintf(clause, valuesClause)) + + f.addWhere(fmt.Sprintf("scene_markers.primary_tag_id NOT IN (SELECT column2 FROM (%s))", valuesClause)) + } + } + } +} + +func (qb *sceneMarkerFilterHandler) sceneTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if tags != nil { + f.addLeftJoin("scenes_tags", "", "scene_markers.scene_id = scenes_tags.scene_id") + + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: "scene_markers", + primaryKey: sceneIDColumn, + foreignTable: tagTable, + foreignFK: tagIDColumn, + + relationsTable: "tags_relations", + joinTable: "scenes_tags", + joinAs: "marker_scenes_tags", + primaryFK: sceneIDColumn, + } + + h.handler(tags).handle(ctx, f) + } + } +} + +func (qb *sceneMarkerFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { + h := joinedMultiCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: performersScenesTable, + joinAs: "performers_join", + primaryFK: sceneIDColumn, + foreignFK: performerIDColumn, + + addJoinTable: func(f *filterBuilder) { + f.addLeftJoin(performersScenesTable, "performers_join", "performers_join.scene_id = scene_markers.scene_id") + }, + } + + handler := h.handler(performers) + return func(ctx context.Context, f *filterBuilder) { + if performers == nil { + return + } + + // Make sure scenes is included, otherwise excludes filter fails + qb.joinScenes(f) + handler(ctx, f) + } +} diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 942a12591..f5528b124 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -2411,10 +2411,12 @@ func TestSceneQueryPathOr(t *testing.T) { Value: scene1Path, Modifier: models.CriterionModifierEquals, }, - Or: &models.SceneFilterType{ - Path: &models.StringCriterionInput{ - Value: scene2Path, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.SceneFilterType]{ + Or: &models.SceneFilterType{ + Path: &models.StringCriterionInput{ + Value: scene2Path, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -2444,10 +2446,12 @@ func TestSceneQueryPathAndRating(t *testing.T) { Value: scenePath, Modifier: models.CriterionModifierEquals, }, - And: &models.SceneFilterType{ - Rating100: &models.IntCriterionInput{ - Value: sceneRating, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.SceneFilterType]{ + And: &models.SceneFilterType{ + Rating100: &models.IntCriterionInput{ + Value: sceneRating, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -2484,8 +2488,10 @@ func TestSceneQueryPathNotRating(t *testing.T) { sceneFilter := models.SceneFilterType{ Path: &pathCriterion, - Not: &models.SceneFilterType{ - Rating100: &ratingCriterion, + OperatorFilter: models.OperatorFilter[models.SceneFilterType]{ + Not: &models.SceneFilterType{ + Rating100: &ratingCriterion, + }, }, } @@ -2516,8 +2522,10 @@ func TestSceneIllegalQuery(t *testing.T) { } sceneFilter := &models.SceneFilterType{ - And: &subFilter, - Or: &subFilter, + OperatorFilter: models.OperatorFilter[models.SceneFilterType]{ + And: &subFilter, + Or: &subFilter, + }, } withTxn(func(ctx context.Context) error { diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 2c5e7d396..780d2e988 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -21,6 +21,11 @@ func distinctIDs(qb *queryBuilder, tableName string) { qb.from = tableName } +func selectIDs(qb *queryBuilder, tableName string) { + qb.addColumn(getColumn(tableName, "id")) + qb.from = tableName +} + func getColumn(tableName string, columnName string) string { return tableName + "." + columnName } diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index e6ab03157..ac6a4a4d9 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -90,8 +90,44 @@ func (r *studioRowRecord) fromPartial(o models.StudioPartial) { r.setBool("ignore_auto_tag", o.IgnoreAutoTag) } -type StudioStore struct { +type studioRepositoryType struct { repository + + stashIDs stashIDRepository + + scenes repository + images repository + galleries repository +} + +var ( + studioRepository = studioRepositoryType{ + repository: repository{ + tableName: studioTable, + idColumn: idColumn, + }, + stashIDs: stashIDRepository{ + repository{ + tableName: "studio_stash_ids", + idColumn: studioIDColumn, + }, + }, + scenes: repository{ + tableName: sceneTable, + idColumn: studioIDColumn, + }, + images: repository{ + tableName: imageTable, + idColumn: studioIDColumn, + }, + galleries: repository{ + tableName: galleryTable, + idColumn: studioIDColumn, + }, + } +) + +type StudioStore struct { blobJoinQueryBuilder tableMgr *table @@ -99,10 +135,6 @@ type StudioStore struct { func NewStudioStore(blobStore *BlobStore) *StudioStore { return &StudioStore{ - repository: repository{ - tableName: studioTable, - idColumn: idColumn, - }, blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, joinTable: studioTable, @@ -147,7 +179,7 @@ func (qb *StudioStore) Create(ctx context.Context, newObject *models.Studio) err } } - updated, _ := qb.find(ctx, id) + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } @@ -220,7 +252,7 @@ func (qb *StudioStore) Destroy(ctx context.Context, id int) error { return err } - return qb.destroyExisting(ctx, []int{id}) + return studioRepository.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found @@ -452,83 +484,6 @@ func (qb *StudioStore) QueryForAutoTag(ctx context.Context, words []string) ([]* return ret, nil } -func (qb *StudioStore) validateFilter(filter *models.StudioFilterType) error { - const and = "AND" - const or = "OR" - const not = "NOT" - - if filter.And != nil { - if filter.Or != nil { - return illegalFilterCombination(and, or) - } - if filter.Not != nil { - return illegalFilterCombination(and, not) - } - - return qb.validateFilter(filter.And) - } - - if filter.Or != nil { - if filter.Not != nil { - return illegalFilterCombination(or, not) - } - - return qb.validateFilter(filter.Or) - } - - if filter.Not != nil { - return qb.validateFilter(filter.Not) - } - - return nil -} - -func (qb *StudioStore) makeFilter(ctx context.Context, studioFilter *models.StudioFilterType) *filterBuilder { - query := &filterBuilder{} - - if studioFilter.And != nil { - query.and(qb.makeFilter(ctx, studioFilter.And)) - } - if studioFilter.Or != nil { - query.or(qb.makeFilter(ctx, studioFilter.Or)) - } - if studioFilter.Not != nil { - query.not(qb.makeFilter(ctx, studioFilter.Not)) - } - - query.handleCriterion(ctx, stringCriterionHandler(studioFilter.Name, studioTable+".name")) - query.handleCriterion(ctx, stringCriterionHandler(studioFilter.Details, studioTable+".details")) - query.handleCriterion(ctx, stringCriterionHandler(studioFilter.URL, studioTable+".url")) - query.handleCriterion(ctx, intCriterionHandler(studioFilter.Rating100, studioTable+".rating", nil)) - query.handleCriterion(ctx, boolCriterionHandler(studioFilter.Favorite, studioTable+".favorite", nil)) - query.handleCriterion(ctx, boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil)) - - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if studioFilter.StashID != nil { - qb.stashIDRepository().join(f, "studio_stash_ids", "studios.id") - stringCriterionHandler(studioFilter.StashID, "studio_stash_ids.stash_id")(ctx, f) - } - })) - query.handleCriterion(ctx, &stashIDCriterionHandler{ - c: studioFilter.StashIDEndpoint, - stashIDRepository: qb.stashIDRepository(), - stashIDTableAs: "studio_stash_ids", - parentIDCol: "studios.id", - }) - - query.handleCriterion(ctx, studioIsMissingCriterionHandler(qb, studioFilter.IsMissing)) - query.handleCriterion(ctx, studioSceneCountCriterionHandler(qb, studioFilter.SceneCount)) - query.handleCriterion(ctx, studioImageCountCriterionHandler(qb, studioFilter.ImageCount)) - query.handleCriterion(ctx, studioGalleryCountCriterionHandler(qb, studioFilter.GalleryCount)) - query.handleCriterion(ctx, studioParentCriterionHandler(qb, studioFilter.Parents)) - query.handleCriterion(ctx, studioAliasCriterionHandler(qb, studioFilter.Aliases)) - query.handleCriterion(ctx, studioChildCountCriterionHandler(qb, studioFilter.ChildCount)) - query.handleCriterion(ctx, timestampCriterionHandler(studioFilter.CreatedAt, studioTable+".created_at")) - query.handleCriterion(ctx, timestampCriterionHandler(studioFilter.UpdatedAt, studioTable+".updated_at")) - - return query -} - func (qb *StudioStore) makeQuery(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if studioFilter == nil { studioFilter = &models.StudioFilterType{} @@ -537,7 +492,7 @@ func (qb *StudioStore) makeQuery(ctx context.Context, studioFilter *models.Studi findFilter = &models.FindFilterType{} } - query := qb.newQuery() + query := studioRepository.newQuery() distinctIDs(&query, studioTable) if q := findFilter.Q; q != nil && *q != "" { @@ -546,10 +501,9 @@ func (qb *StudioStore) makeQuery(ctx context.Context, studioFilter *models.Studi query.parseQueryString(searchColumns, *q) } - if err := qb.validateFilter(studioFilter); err != nil { - return nil, err - } - filter := qb.makeFilter(ctx, studioFilter) + filter := filterBuilderFromHandler(ctx, &studioFilterHandler{ + studioFilter: studioFilter, + }) if err := query.addFilter(filter); err != nil { return nil, err @@ -584,93 +538,6 @@ func (qb *StudioStore) Query(ctx context.Context, studioFilter *models.StudioFil return studios, countResult, nil } -func studioIsMissingCriterionHandler(qb *StudioStore, isMissing *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if isMissing != nil && *isMissing != "" { - switch *isMissing { - case "image": - f.addWhere("studios.image_blob IS NULL") - case "stash_id": - qb.stashIDRepository().join(f, "studio_stash_ids", "studios.id") - f.addWhere("studio_stash_ids.studio_id IS NULL") - default: - f.addWhere("(studios." + *isMissing + " IS NULL OR TRIM(studios." + *isMissing + ") = '')") - } - } - } -} - -func studioSceneCountCriterionHandler(qb *StudioStore, sceneCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if sceneCount != nil { - f.addLeftJoin("scenes", "", "scenes.studio_id = studios.id") - clause, args := getIntCriterionWhereClause("count(distinct scenes.id)", *sceneCount) - - f.addHaving(clause, args...) - } - } -} - -func studioImageCountCriterionHandler(qb *StudioStore, imageCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if imageCount != nil { - f.addLeftJoin("images", "", "images.studio_id = studios.id") - clause, args := getIntCriterionWhereClause("count(distinct images.id)", *imageCount) - - f.addHaving(clause, args...) - } - } -} - -func studioGalleryCountCriterionHandler(qb *StudioStore, galleryCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if galleryCount != nil { - f.addLeftJoin("galleries", "", "galleries.studio_id = studios.id") - clause, args := getIntCriterionWhereClause("count(distinct galleries.id)", *galleryCount) - - f.addHaving(clause, args...) - } - } -} - -func studioParentCriterionHandler(qb *StudioStore, parents *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - f.addLeftJoin("studios", "parent_studio", "parent_studio.id = studios.parent_id") - } - h := multiCriterionHandlerBuilder{ - primaryTable: studioTable, - foreignTable: "parent_studio", - joinTable: "", - primaryFK: studioIDColumn, - foreignFK: "parent_id", - addJoinsFunc: addJoinsFunc, - } - return h.handler(parents) -} - -func studioAliasCriterionHandler(qb *StudioStore, alias *models.StringCriterionInput) criterionHandlerFunc { - h := stringListCriterionHandlerBuilder{ - joinTable: studioAliasesTable, - stringColumn: studioAliasColumn, - addJoinTable: func(f *filterBuilder) { - studiosAliasesTableMgr.join(f, "", "studios.id") - }, - } - - return h.handler(alias) -} - -func studioChildCountCriterionHandler(qb *StudioStore, childCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if childCount != nil { - f.addLeftJoin("studios", "children_count", "children_count.parent_id = studios.id") - clause, args := getIntCriterionWhereClause("count(distinct children_count.id)", *childCount) - - f.addHaving(clause, args...) - } - } -} - var studioSortOptions = sortOptions{ "child_count", "created_at", @@ -735,16 +602,6 @@ func (qb *StudioStore) destroyImage(ctx context.Context, studioID int) error { return qb.blobJoinQueryBuilder.DestroyImage(ctx, studioID, studioImageBlobColumn) } -func (qb *StudioStore) stashIDRepository() *stashIDRepository { - return &stashIDRepository{ - repository{ - tx: qb.tx, - tableName: "studio_stash_ids", - idColumn: studioIDColumn, - }, - } -} - func (qb *StudioStore) GetStashIDs(ctx context.Context, studioID int) ([]models.StashID, error) { return studiosStashIDsTableMgr.get(ctx, studioID) } diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go new file mode 100644 index 000000000..1a3aa2131 --- /dev/null +++ b/pkg/sqlite/studio_filter.go @@ -0,0 +1,200 @@ +package sqlite + +import ( + "context" + + "github.com/stashapp/stash/pkg/models" +) + +type studioFilterHandler struct { + studioFilter *models.StudioFilterType +} + +func (qb *studioFilterHandler) validate() error { + studioFilter := qb.studioFilter + if studioFilter == nil { + return nil + } + + if err := validateFilterCombination(studioFilter.OperatorFilter); err != nil { + return err + } + + if subFilter := studioFilter.SubFilter(); subFilter != nil { + sqb := &studioFilterHandler{studioFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + return nil +} + +func (qb *studioFilterHandler) handle(ctx context.Context, f *filterBuilder) { + studioFilter := qb.studioFilter + if studioFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := studioFilter.SubFilter() + if sf != nil { + sub := &studioFilterHandler{sf} + handleSubFilter(ctx, sub, f, studioFilter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *studioFilterHandler) criterionHandler() criterionHandler { + studioFilter := qb.studioFilter + return compoundHandler{ + stringCriterionHandler(studioFilter.Name, studioTable+".name"), + stringCriterionHandler(studioFilter.Details, studioTable+".details"), + stringCriterionHandler(studioFilter.URL, studioTable+".url"), + intCriterionHandler(studioFilter.Rating100, studioTable+".rating", nil), + boolCriterionHandler(studioFilter.Favorite, studioTable+".favorite", nil), + boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if studioFilter.StashID != nil { + studioRepository.stashIDs.join(f, "studio_stash_ids", "studios.id") + stringCriterionHandler(studioFilter.StashID, "studio_stash_ids.stash_id")(ctx, f) + } + }), + &stashIDCriterionHandler{ + c: studioFilter.StashIDEndpoint, + stashIDRepository: &studioRepository.stashIDs, + stashIDTableAs: "studio_stash_ids", + parentIDCol: "studios.id", + }, + + qb.isMissingCriterionHandler(studioFilter.IsMissing), + qb.sceneCountCriterionHandler(studioFilter.SceneCount), + qb.imageCountCriterionHandler(studioFilter.ImageCount), + qb.galleryCountCriterionHandler(studioFilter.GalleryCount), + qb.parentCriterionHandler(studioFilter.Parents), + qb.aliasCriterionHandler(studioFilter.Aliases), + qb.childCountCriterionHandler(studioFilter.ChildCount), + ×tampCriterionHandler{studioFilter.CreatedAt, studioTable + ".created_at", nil}, + ×tampCriterionHandler{studioFilter.UpdatedAt, studioTable + ".updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "scenes.id", + relatedRepo: sceneRepository.repository, + relatedHandler: &sceneFilterHandler{studioFilter.ScenesFilter}, + joinFn: func(f *filterBuilder) { + sceneRepository.innerJoin(f, "", "studios.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "images.id", + relatedRepo: imageRepository.repository, + relatedHandler: &imageFilterHandler{studioFilter.ImagesFilter}, + joinFn: func(f *filterBuilder) { + studioRepository.images.innerJoin(f, "", "studios.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "galleries.id", + relatedRepo: galleryRepository.repository, + relatedHandler: &galleryFilterHandler{studioFilter.GalleriesFilter}, + joinFn: func(f *filterBuilder) { + studioRepository.galleries.innerJoin(f, "", "studios.id") + }, + }, + } +} + +func (qb *studioFilterHandler) isMissingCriterionHandler(isMissing *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "image": + f.addWhere("studios.image_blob IS NULL") + case "stash_id": + studioRepository.stashIDs.join(f, "studio_stash_ids", "studios.id") + f.addWhere("studio_stash_ids.studio_id IS NULL") + default: + f.addWhere("(studios." + *isMissing + " IS NULL OR TRIM(studios." + *isMissing + ") = '')") + } + } + } +} + +func (qb *studioFilterHandler) sceneCountCriterionHandler(sceneCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if sceneCount != nil { + f.addLeftJoin("scenes", "", "scenes.studio_id = studios.id") + clause, args := getIntCriterionWhereClause("count(distinct scenes.id)", *sceneCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *studioFilterHandler) imageCountCriterionHandler(imageCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if imageCount != nil { + f.addLeftJoin("images", "", "images.studio_id = studios.id") + clause, args := getIntCriterionWhereClause("count(distinct images.id)", *imageCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *studioFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if galleryCount != nil { + f.addLeftJoin("galleries", "", "galleries.studio_id = studios.id") + clause, args := getIntCriterionWhereClause("count(distinct galleries.id)", *galleryCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *studioFilterHandler) parentCriterionHandler(parents *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + f.addLeftJoin("studios", "parent_studio", "parent_studio.id = studios.parent_id") + } + h := multiCriterionHandlerBuilder{ + primaryTable: studioTable, + foreignTable: "parent_studio", + joinTable: "", + primaryFK: studioIDColumn, + foreignFK: "parent_id", + addJoinsFunc: addJoinsFunc, + } + return h.handler(parents) +} + +func (qb *studioFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: studioAliasesTable, + stringColumn: studioAliasColumn, + addJoinTable: func(f *filterBuilder) { + studiosAliasesTableMgr.join(f, "", "studios.id") + }, + } + + return h.handler(alias) +} + +func (qb *studioFilterHandler) childCountCriterionHandler(childCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if childCount != nil { + f.addLeftJoin("studios", "children_count", "children_count.parent_id = studios.id") + clause, args := getIntCriterionWhereClause("count(distinct children_count.id)", *childCount) + + f.addHaving(clause, args...) + } + } +} diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index 25f8ea195..c75c2a61f 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -59,10 +59,12 @@ func TestStudioQueryNameOr(t *testing.T) { Value: studio1Name, Modifier: models.CriterionModifierEquals, }, - Or: &models.StudioFilterType{ - Name: &models.StringCriterionInput{ - Value: studio2Name, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.StudioFilterType]{ + Or: &models.StudioFilterType{ + Name: &models.StringCriterionInput{ + Value: studio2Name, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -90,10 +92,12 @@ func TestStudioQueryNameAndUrl(t *testing.T) { Value: studioName, Modifier: models.CriterionModifierEquals, }, - And: &models.StudioFilterType{ - URL: &models.StringCriterionInput{ - Value: studioUrl, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.StudioFilterType]{ + And: &models.StudioFilterType{ + URL: &models.StringCriterionInput{ + Value: studioUrl, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -128,8 +132,10 @@ func TestStudioQueryNameNotUrl(t *testing.T) { studioFilter := models.StudioFilterType{ Name: &nameCriterion, - Not: &models.StudioFilterType{ - URL: &urlCriterion, + OperatorFilter: models.OperatorFilter[models.StudioFilterType]{ + Not: &models.StudioFilterType{ + URL: &urlCriterion, + }, }, } @@ -160,8 +166,10 @@ func TestStudioIllegalQuery(t *testing.T) { } studioFilter := &models.StudioFilterType{ - And: &subFilter, - Or: &subFilter, + OperatorFilter: models.OperatorFilter[models.StudioFilterType]{ + And: &subFilter, + Or: &subFilter, + }, } withTxn(func(ctx context.Context) error { diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index a04504281..2aa5b77b6 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -193,8 +193,7 @@ func (t *joinTable) insertJoins(ctx context.Context, id int, foreignIDs []int) e // ignore duplicates q := fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?) ON CONFLICT (%[2]s, %s) DO NOTHING", t.table.table.GetTable(), t.idColumn.GetCol(), t.fkColumn.GetCol()) - tx := dbWrapper{} - stmt, err := tx.Prepare(ctx, q) + stmt, err := dbWrapper.Prepare(ctx, q) if err != nil { return err } @@ -204,7 +203,7 @@ func (t *joinTable) insertJoins(ctx context.Context, id int, foreignIDs []int) e foreignIDs = sliceutil.AppendUniques(nil, foreignIDs) for _, fk := range foreignIDs { - if _, err := tx.ExecStmt(ctx, stmt, id, fk); err != nil { + if _, err := dbWrapper.ExecStmt(ctx, stmt, id, fk); err != nil { return err } } @@ -1077,8 +1076,7 @@ func queryFunc(ctx context.Context, query *goqu.SelectDataset, single bool, f fu return err } - wrapper := dbWrapper{} - rows, err := wrapper.QueryxContext(ctx, q, args...) + rows, err := dbWrapper.QueryxContext(ctx, q, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("querying `%s` [%v]: %w", q, args, err) @@ -1107,8 +1105,7 @@ func querySimple(ctx context.Context, query *goqu.SelectDataset, out interface{} return err } - wrapper := dbWrapper{} - rows, err := wrapper.QueryxContext(ctx, q, args...) + rows, err := dbWrapper.QueryxContext(ctx, q, args...) if err != nil { return fmt.Errorf("querying `%s` [%v]: %w", q, args, err) } diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index cfed64bfc..99cc42edc 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -90,8 +90,57 @@ func (r *tagRowRecord) fromPartial(o models.TagPartial) { r.setTimestamp("updated_at", o.UpdatedAt) } -type TagStore struct { +type tagRepositoryType struct { repository + + aliases stringRepository + + scenes joinRepository + images joinRepository + galleries joinRepository +} + +var ( + tagRepository = tagRepositoryType{ + repository: repository{ + tableName: tagTable, + idColumn: idColumn, + }, + aliases: stringRepository{ + repository: repository{ + tableName: tagAliasesTable, + idColumn: tagIDColumn, + }, + stringColumn: tagAliasColumn, + }, + scenes: joinRepository{ + repository: repository{ + tableName: scenesTagsTable, + idColumn: tagIDColumn, + }, + fkColumn: sceneIDColumn, + foreignTable: sceneTable, + }, + images: joinRepository{ + repository: repository{ + tableName: imagesTagsTable, + idColumn: tagIDColumn, + }, + fkColumn: imageIDColumn, + foreignTable: imageTable, + }, + galleries: joinRepository{ + repository: repository{ + tableName: galleriesTagsTable, + idColumn: tagIDColumn, + }, + fkColumn: galleryIDColumn, + foreignTable: galleryTable, + }, + } +) + +type TagStore struct { blobJoinQueryBuilder tableMgr *table @@ -99,10 +148,6 @@ type TagStore struct { func NewTagStore(blobStore *BlobStore) *TagStore { return &TagStore{ - repository: repository{ - tableName: tagTable, - idColumn: idColumn, - }, blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, joinTable: tagTable, @@ -176,7 +221,7 @@ func (qb *TagStore) Destroy(ctx context.Context, id int) error { // cannot unset primary_tag_id in scene_markers because it is not nullable countQuery := "SELECT COUNT(*) as count FROM scene_markers where primary_tag_id = ?" args := []interface{}{id} - primaryMarkers, err := qb.runCountQuery(ctx, countQuery, args) + primaryMarkers, err := tagRepository.runCountQuery(ctx, countQuery, args) if err != nil { return err } @@ -185,7 +230,7 @@ func (qb *TagStore) Destroy(ctx context.Context, id int) error { return errors.New("cannot delete tag used as a primary tag in scene markers") } - return qb.destroyExisting(ctx, []int{id}) + return tagRepository.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found @@ -455,73 +500,6 @@ func (qb *TagStore) QueryForAutoTag(ctx context.Context, words []string) ([]*mod return qb.queryTags(ctx, query+" WHERE "+where, args) } -func (qb *TagStore) validateFilter(tagFilter *models.TagFilterType) error { - const and = "AND" - const or = "OR" - const not = "NOT" - - if tagFilter.And != nil { - if tagFilter.Or != nil { - return illegalFilterCombination(and, or) - } - if tagFilter.Not != nil { - return illegalFilterCombination(and, not) - } - - return qb.validateFilter(tagFilter.And) - } - - if tagFilter.Or != nil { - if tagFilter.Not != nil { - return illegalFilterCombination(or, not) - } - - return qb.validateFilter(tagFilter.Or) - } - - if tagFilter.Not != nil { - return qb.validateFilter(tagFilter.Not) - } - - return nil -} - -func (qb *TagStore) makeFilter(ctx context.Context, tagFilter *models.TagFilterType) *filterBuilder { - query := &filterBuilder{} - - if tagFilter.And != nil { - query.and(qb.makeFilter(ctx, tagFilter.And)) - } - if tagFilter.Or != nil { - query.or(qb.makeFilter(ctx, tagFilter.Or)) - } - if tagFilter.Not != nil { - query.not(qb.makeFilter(ctx, tagFilter.Not)) - } - - query.handleCriterion(ctx, stringCriterionHandler(tagFilter.Name, tagTable+".name")) - query.handleCriterion(ctx, tagAliasCriterionHandler(qb, tagFilter.Aliases)) - - query.handleCriterion(ctx, boolCriterionHandler(tagFilter.Favorite, tagTable+".favorite", nil)) - query.handleCriterion(ctx, stringCriterionHandler(tagFilter.Description, tagTable+".description")) - query.handleCriterion(ctx, boolCriterionHandler(tagFilter.IgnoreAutoTag, tagTable+".ignore_auto_tag", nil)) - - query.handleCriterion(ctx, tagIsMissingCriterionHandler(qb, tagFilter.IsMissing)) - query.handleCriterion(ctx, tagSceneCountCriterionHandler(qb, tagFilter.SceneCount)) - query.handleCriterion(ctx, tagImageCountCriterionHandler(qb, tagFilter.ImageCount)) - query.handleCriterion(ctx, tagGalleryCountCriterionHandler(qb, tagFilter.GalleryCount)) - query.handleCriterion(ctx, tagPerformerCountCriterionHandler(qb, tagFilter.PerformerCount)) - query.handleCriterion(ctx, tagMarkerCountCriterionHandler(qb, tagFilter.MarkerCount)) - query.handleCriterion(ctx, tagParentsCriterionHandler(qb, tagFilter.Parents)) - query.handleCriterion(ctx, tagChildrenCriterionHandler(qb, tagFilter.Children)) - query.handleCriterion(ctx, tagParentCountCriterionHandler(qb, tagFilter.ParentCount)) - query.handleCriterion(ctx, tagChildCountCriterionHandler(qb, tagFilter.ChildCount)) - query.handleCriterion(ctx, timestampCriterionHandler(tagFilter.CreatedAt, "tags.created_at")) - query.handleCriterion(ctx, timestampCriterionHandler(tagFilter.UpdatedAt, "tags.updated_at")) - - return query -} - func (qb *TagStore) Query(ctx context.Context, tagFilter *models.TagFilterType, findFilter *models.FindFilterType) ([]*models.Tag, int, error) { if tagFilter == nil { tagFilter = &models.TagFilterType{} @@ -530,7 +508,7 @@ func (qb *TagStore) Query(ctx context.Context, tagFilter *models.TagFilterType, findFilter = &models.FindFilterType{} } - query := qb.newQuery() + query := tagRepository.newQuery() distinctIDs(&query, tagTable) if q := findFilter.Q; q != nil && *q != "" { @@ -539,10 +517,9 @@ func (qb *TagStore) Query(ctx context.Context, tagFilter *models.TagFilterType, query.parseQueryString(searchColumns, *q) } - if err := qb.validateFilter(tagFilter); err != nil { - return nil, 0, err - } - filter := qb.makeFilter(ctx, tagFilter) + filter := filterBuilderFromHandler(ctx, &tagFilterHandler{ + tagFilter: tagFilter, + }) if err := query.addFilter(filter); err != nil { return nil, 0, err @@ -567,297 +544,6 @@ func (qb *TagStore) Query(ctx context.Context, tagFilter *models.TagFilterType, return tags, countResult, nil } -func tagAliasCriterionHandler(qb *TagStore, alias *models.StringCriterionInput) criterionHandlerFunc { - h := stringListCriterionHandlerBuilder{ - joinTable: tagAliasesTable, - stringColumn: tagAliasColumn, - addJoinTable: func(f *filterBuilder) { - qb.aliasRepository().join(f, "", "tags.id") - }, - } - - return h.handler(alias) -} - -func tagIsMissingCriterionHandler(qb *TagStore, isMissing *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if isMissing != nil && *isMissing != "" { - switch *isMissing { - case "image": - f.addWhere("tags.image_blob IS NULL") - default: - f.addWhere("(tags." + *isMissing + " IS NULL OR TRIM(tags." + *isMissing + ") = '')") - } - } - } -} - -func tagSceneCountCriterionHandler(qb *TagStore, sceneCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if sceneCount != nil { - f.addLeftJoin("scenes_tags", "", "scenes_tags.tag_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct scenes_tags.scene_id)", *sceneCount) - - f.addHaving(clause, args...) - } - } -} - -func tagImageCountCriterionHandler(qb *TagStore, imageCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if imageCount != nil { - f.addLeftJoin("images_tags", "", "images_tags.tag_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct images_tags.image_id)", *imageCount) - - f.addHaving(clause, args...) - } - } -} - -func tagGalleryCountCriterionHandler(qb *TagStore, galleryCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if galleryCount != nil { - f.addLeftJoin("galleries_tags", "", "galleries_tags.tag_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct galleries_tags.gallery_id)", *galleryCount) - - f.addHaving(clause, args...) - } - } -} - -func tagPerformerCountCriterionHandler(qb *TagStore, performerCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performerCount != nil { - f.addLeftJoin("performers_tags", "", "performers_tags.tag_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct performers_tags.performer_id)", *performerCount) - - f.addHaving(clause, args...) - } - } -} - -func tagMarkerCountCriterionHandler(qb *TagStore, markerCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if markerCount != nil { - f.addLeftJoin("scene_markers_tags", "", "scene_markers_tags.tag_id = tags.id") - f.addLeftJoin("scene_markers", "", "scene_markers_tags.scene_marker_id = scene_markers.id OR scene_markers.primary_tag_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct scene_markers.id)", *markerCount) - - f.addHaving(clause, args...) - } - } -} - -func tagParentsCriterionHandler(qb *TagStore, criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { - tags := criterion.CombineExcludes() - - // validate the modifier - switch tags.Modifier { - case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: - // valid - default: - f.setError(fmt.Errorf("invalid modifier %s for tag parent/children", criterion.Modifier)) - } - - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("tags_relations", "parent_relations", "tags.id = parent_relations.child_id") - - f.addWhere(fmt.Sprintf("parent_relations.parent_id IS %s NULL", notClause)) - return - } - - if len(tags.Value) == 0 && len(tags.Excludes) == 0 { - return - } - - if len(tags.Value) > 0 { - var args []interface{} - for _, val := range tags.Value { - args = append(args, val) - } - - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth - } - - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) - } - - query := `parents AS ( - SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Value)) + ` - UNION - SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents ON item_id = parent_id ` + depthCondition + ` - )` - - f.addRecursiveWith(query, args...) - - f.addLeftJoin("parents", "", "parents.item_id = tags.id") - - addHierarchicalConditionClauses(f, tags, "parents", "root_id") - } - - if len(tags.Excludes) > 0 { - var args []interface{} - for _, val := range tags.Excludes { - args = append(args, val) - } - - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth - } - - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) - } - - query := `parents2 AS ( - SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Excludes)) + ` - UNION - SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents2 ON item_id = parent_id ` + depthCondition + ` - )` - - f.addRecursiveWith(query, args...) - - f.addLeftJoin("parents2", "", "parents2.item_id = tags.id") - - addHierarchicalConditionClauses(f, models.HierarchicalMultiCriterionInput{ - Value: tags.Excludes, - Depth: tags.Depth, - Modifier: models.CriterionModifierExcludes, - }, "parents2", "root_id") - } - } - } -} - -func tagChildrenCriterionHandler(qb *TagStore, criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { - tags := criterion.CombineExcludes() - - // validate the modifier - switch tags.Modifier { - case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: - // valid - default: - f.setError(fmt.Errorf("invalid modifier %s for tag parent/children", criterion.Modifier)) - } - - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("tags_relations", "child_relations", "tags.id = child_relations.parent_id") - - f.addWhere(fmt.Sprintf("child_relations.child_id IS %s NULL", notClause)) - return - } - - if len(tags.Value) == 0 && len(tags.Excludes) == 0 { - return - } - - if len(tags.Value) > 0 { - var args []interface{} - for _, val := range tags.Value { - args = append(args, val) - } - - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth - } - - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) - } - - query := `children AS ( - SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Value)) + ` - UNION - SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children ON item_id = child_id ` + depthCondition + ` - )` - - f.addRecursiveWith(query, args...) - - f.addLeftJoin("children", "", "children.item_id = tags.id") - - addHierarchicalConditionClauses(f, tags, "children", "root_id") - } - - if len(tags.Excludes) > 0 { - var args []interface{} - for _, val := range tags.Excludes { - args = append(args, val) - } - - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth - } - - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) - } - - query := `children2 AS ( - SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Excludes)) + ` - UNION - SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children2 ON item_id = child_id ` + depthCondition + ` - )` - - f.addRecursiveWith(query, args...) - - f.addLeftJoin("children2", "", "children2.item_id = tags.id") - - addHierarchicalConditionClauses(f, models.HierarchicalMultiCriterionInput{ - Value: tags.Excludes, - Depth: tags.Depth, - Modifier: models.CriterionModifierExcludes, - }, "children2", "root_id") - } - } - } -} - -func tagParentCountCriterionHandler(qb *TagStore, parentCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if parentCount != nil { - f.addLeftJoin("tags_relations", "parents_count", "parents_count.child_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct parents_count.parent_id)", *parentCount) - - f.addHaving(clause, args...) - } - } -} - -func tagChildCountCriterionHandler(qb *TagStore, childCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if childCount != nil { - f.addLeftJoin("tags_relations", "children_count", "children_count.parent_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct children_count.child_id)", *childCount) - - f.addHaving(clause, args...) - } - } -} - var tagSortOptions = sortOptions{ "created_at", "galleries_count", @@ -915,7 +601,7 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte func (qb *TagStore) queryTags(ctx context.Context, query string, args []interface{}) ([]*models.Tag, error) { const single = false var ret []*models.Tag - if err := qb.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { + if err := tagRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { var f tagRow if err := r.StructScan(&f); err != nil { return err @@ -935,7 +621,7 @@ func (qb *TagStore) queryTags(ctx context.Context, query string, args []interfac func (qb *TagStore) queryTagPaths(ctx context.Context, query string, args []interface{}) ([]*models.TagPath, error) { const single = false var ret []*models.TagPath - if err := qb.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { + if err := tagRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { var f tagPathRow if err := r.StructScan(&f); err != nil { return err @@ -968,23 +654,12 @@ func (qb *TagStore) destroyImage(ctx context.Context, tagID int) error { return qb.blobJoinQueryBuilder.DestroyImage(ctx, tagID, tagImageBlobColumn) } -func (qb *TagStore) aliasRepository() *stringRepository { - return &stringRepository{ - repository: repository{ - tx: qb.tx, - tableName: tagAliasesTable, - idColumn: tagIDColumn, - }, - stringColumn: tagAliasColumn, - } -} - func (qb *TagStore) GetAliases(ctx context.Context, tagID int) ([]string, error) { - return qb.aliasRepository().get(ctx, tagID) + return tagRepository.aliases.get(ctx, tagID) } func (qb *TagStore) UpdateAliases(ctx context.Context, tagID int, aliases []string) error { - return qb.aliasRepository().replace(ctx, tagID, aliases) + return tagRepository.aliases.replace(ctx, tagID, aliases) } func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) error { @@ -1015,7 +690,7 @@ func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) er args = append(args, destination) for table, idColumn := range tagTables { - _, err := qb.tx.Exec(ctx, `UPDATE OR IGNORE `+table+` + _, err := dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+table+` SET tag_id = ? WHERE tag_id IN `+inBinding+` AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idColumn+` AND o.tag_id = ?)`, @@ -1026,22 +701,22 @@ AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idCo } // delete source tag ids from the table where they couldn't be set - if _, err := qb.tx.Exec(ctx, `DELETE FROM `+table+` WHERE tag_id IN `+inBinding, srcArgs...); err != nil { + if _, err := dbWrapper.Exec(ctx, `DELETE FROM `+table+` WHERE tag_id IN `+inBinding, srcArgs...); err != nil { return err } } - _, err := qb.tx.Exec(ctx, "UPDATE "+sceneMarkerTable+" SET primary_tag_id = ? WHERE primary_tag_id IN "+inBinding, args...) + _, err := dbWrapper.Exec(ctx, "UPDATE "+sceneMarkerTable+" SET primary_tag_id = ? WHERE primary_tag_id IN "+inBinding, args...) if err != nil { return err } - _, err = qb.tx.Exec(ctx, "INSERT INTO "+tagAliasesTable+" (tag_id, alias) SELECT ?, name FROM "+tagTable+" WHERE id IN "+inBinding, args...) + _, err = dbWrapper.Exec(ctx, "INSERT INTO "+tagAliasesTable+" (tag_id, alias) SELECT ?, name FROM "+tagTable+" WHERE id IN "+inBinding, args...) if err != nil { return err } - _, err = qb.tx.Exec(ctx, "UPDATE "+tagAliasesTable+" SET tag_id = ? WHERE tag_id IN "+inBinding, args...) + _, err = dbWrapper.Exec(ctx, "UPDATE "+tagAliasesTable+" SET tag_id = ? WHERE tag_id IN "+inBinding, args...) if err != nil { return err } @@ -1057,8 +732,7 @@ AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idCo } func (qb *TagStore) UpdateParentTags(ctx context.Context, tagID int, parentIDs []int) error { - tx := qb.tx - if _, err := tx.Exec(ctx, "DELETE FROM tags_relations WHERE child_id = ?", tagID); err != nil { + if _, err := dbWrapper.Exec(ctx, "DELETE FROM tags_relations WHERE child_id = ?", tagID); err != nil { return err } @@ -1071,7 +745,7 @@ func (qb *TagStore) UpdateParentTags(ctx context.Context, tagID int, parentIDs [ } query := "INSERT INTO tags_relations (parent_id, child_id) VALUES " + strings.Join(values, ", ") - if _, err := tx.Exec(ctx, query, args...); err != nil { + if _, err := dbWrapper.Exec(ctx, query, args...); err != nil { return err } } @@ -1080,8 +754,7 @@ func (qb *TagStore) UpdateParentTags(ctx context.Context, tagID int, parentIDs [ } func (qb *TagStore) UpdateChildTags(ctx context.Context, tagID int, childIDs []int) error { - tx := qb.tx - if _, err := tx.Exec(ctx, "DELETE FROM tags_relations WHERE parent_id = ?", tagID); err != nil { + if _, err := dbWrapper.Exec(ctx, "DELETE FROM tags_relations WHERE parent_id = ?", tagID); err != nil { return err } @@ -1094,7 +767,7 @@ func (qb *TagStore) UpdateChildTags(ctx context.Context, tagID int, childIDs []i } query := "INSERT INTO tags_relations (parent_id, child_id) VALUES " + strings.Join(values, ", ") - if _, err := tx.Exec(ctx, query, args...); err != nil { + if _, err := dbWrapper.Exec(ctx, query, args...); err != nil { return err } } diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go new file mode 100644 index 000000000..a628a0735 --- /dev/null +++ b/pkg/sqlite/tag_filter.go @@ -0,0 +1,395 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +type tagFilterHandler struct { + tagFilter *models.TagFilterType +} + +func (qb *tagFilterHandler) validate() error { + tagFilter := qb.tagFilter + if tagFilter == nil { + return nil + } + + if err := validateFilterCombination(tagFilter.OperatorFilter); err != nil { + return err + } + + if subFilter := tagFilter.SubFilter(); subFilter != nil { + sqb := &tagFilterHandler{tagFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + return nil +} + +func (qb *tagFilterHandler) handle(ctx context.Context, f *filterBuilder) { + tagFilter := qb.tagFilter + if tagFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := tagFilter.SubFilter() + if sf != nil { + sub := &tagFilterHandler{sf} + handleSubFilter(ctx, sub, f, tagFilter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *tagFilterHandler) criterionHandler() criterionHandler { + tagFilter := qb.tagFilter + return compoundHandler{ + stringCriterionHandler(tagFilter.Name, tagTable+".name"), + qb.aliasCriterionHandler(tagFilter.Aliases), + + boolCriterionHandler(tagFilter.Favorite, tagTable+".favorite", nil), + stringCriterionHandler(tagFilter.Description, tagTable+".description"), + boolCriterionHandler(tagFilter.IgnoreAutoTag, tagTable+".ignore_auto_tag", nil), + + qb.isMissingCriterionHandler(tagFilter.IsMissing), + qb.sceneCountCriterionHandler(tagFilter.SceneCount), + qb.imageCountCriterionHandler(tagFilter.ImageCount), + qb.galleryCountCriterionHandler(tagFilter.GalleryCount), + qb.performerCountCriterionHandler(tagFilter.PerformerCount), + qb.markerCountCriterionHandler(tagFilter.MarkerCount), + qb.parentsCriterionHandler(tagFilter.Parents), + qb.childrenCriterionHandler(tagFilter.Children), + qb.parentCountCriterionHandler(tagFilter.ParentCount), + qb.childCountCriterionHandler(tagFilter.ChildCount), + ×tampCriterionHandler{tagFilter.CreatedAt, "tags.created_at", nil}, + ×tampCriterionHandler{tagFilter.UpdatedAt, "tags.updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "scenes_tags.scene_id", + relatedRepo: sceneRepository.repository, + relatedHandler: &sceneFilterHandler{tagFilter.ScenesFilter}, + joinFn: func(f *filterBuilder) { + tagRepository.scenes.innerJoin(f, "", "tags.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "images_tags.image_id", + relatedRepo: imageRepository.repository, + relatedHandler: &imageFilterHandler{tagFilter.ImagesFilter}, + joinFn: func(f *filterBuilder) { + tagRepository.images.innerJoin(f, "", "tags.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "galleries_tags.gallery_id", + relatedRepo: galleryRepository.repository, + relatedHandler: &galleryFilterHandler{tagFilter.GalleriesFilter}, + joinFn: func(f *filterBuilder) { + tagRepository.galleries.innerJoin(f, "", "tags.id") + }, + }, + } +} + +func (qb *tagFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: tagAliasesTable, + stringColumn: tagAliasColumn, + addJoinTable: func(f *filterBuilder) { + tagRepository.aliases.join(f, "", "tags.id") + }, + } + + return h.handler(alias) +} + +func (qb *tagFilterHandler) isMissingCriterionHandler(isMissing *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "image": + f.addWhere("tags.image_blob IS NULL") + default: + f.addWhere("(tags." + *isMissing + " IS NULL OR TRIM(tags." + *isMissing + ") = '')") + } + } + } +} + +func (qb *tagFilterHandler) sceneCountCriterionHandler(sceneCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if sceneCount != nil { + f.addLeftJoin("scenes_tags", "", "scenes_tags.tag_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct scenes_tags.scene_id)", *sceneCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *tagFilterHandler) imageCountCriterionHandler(imageCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if imageCount != nil { + f.addLeftJoin("images_tags", "", "images_tags.tag_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct images_tags.image_id)", *imageCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *tagFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if galleryCount != nil { + f.addLeftJoin("galleries_tags", "", "galleries_tags.tag_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct galleries_tags.gallery_id)", *galleryCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *tagFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerCount != nil { + f.addLeftJoin("performers_tags", "", "performers_tags.tag_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct performers_tags.performer_id)", *performerCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *tagFilterHandler) markerCountCriterionHandler(markerCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if markerCount != nil { + f.addLeftJoin("scene_markers_tags", "", "scene_markers_tags.tag_id = tags.id") + f.addLeftJoin("scene_markers", "", "scene_markers_tags.scene_marker_id = scene_markers.id OR scene_markers.primary_tag_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct scene_markers.id)", *markerCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *tagFilterHandler) parentsCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + tags := criterion.CombineExcludes() + + // validate the modifier + switch tags.Modifier { + case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: + // valid + default: + f.setError(fmt.Errorf("invalid modifier %s for tag parent/children", criterion.Modifier)) + } + + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { + var notClause string + if tags.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addLeftJoin("tags_relations", "parent_relations", "tags.id = parent_relations.child_id") + + f.addWhere(fmt.Sprintf("parent_relations.parent_id IS %s NULL", notClause)) + return + } + + if len(tags.Value) == 0 && len(tags.Excludes) == 0 { + return + } + + if len(tags.Value) > 0 { + var args []interface{} + for _, val := range tags.Value { + args = append(args, val) + } + + depthVal := 0 + if tags.Depth != nil { + depthVal = *tags.Depth + } + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + query := `parents AS ( + SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Value)) + ` + UNION + SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents ON item_id = parent_id ` + depthCondition + ` + )` + + f.addRecursiveWith(query, args...) + + f.addLeftJoin("parents", "", "parents.item_id = tags.id") + + addHierarchicalConditionClauses(f, tags, "parents", "root_id") + } + + if len(tags.Excludes) > 0 { + var args []interface{} + for _, val := range tags.Excludes { + args = append(args, val) + } + + depthVal := 0 + if tags.Depth != nil { + depthVal = *tags.Depth + } + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + query := `parents2 AS ( + SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Excludes)) + ` + UNION + SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents2 ON item_id = parent_id ` + depthCondition + ` + )` + + f.addRecursiveWith(query, args...) + + f.addLeftJoin("parents2", "", "parents2.item_id = tags.id") + + addHierarchicalConditionClauses(f, models.HierarchicalMultiCriterionInput{ + Value: tags.Excludes, + Depth: tags.Depth, + Modifier: models.CriterionModifierExcludes, + }, "parents2", "root_id") + } + } + } +} + +func (qb *tagFilterHandler) childrenCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + tags := criterion.CombineExcludes() + + // validate the modifier + switch tags.Modifier { + case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: + // valid + default: + f.setError(fmt.Errorf("invalid modifier %s for tag parent/children", criterion.Modifier)) + } + + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { + var notClause string + if tags.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addLeftJoin("tags_relations", "child_relations", "tags.id = child_relations.parent_id") + + f.addWhere(fmt.Sprintf("child_relations.child_id IS %s NULL", notClause)) + return + } + + if len(tags.Value) == 0 && len(tags.Excludes) == 0 { + return + } + + if len(tags.Value) > 0 { + var args []interface{} + for _, val := range tags.Value { + args = append(args, val) + } + + depthVal := 0 + if tags.Depth != nil { + depthVal = *tags.Depth + } + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + query := `children AS ( + SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Value)) + ` + UNION + SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children ON item_id = child_id ` + depthCondition + ` + )` + + f.addRecursiveWith(query, args...) + + f.addLeftJoin("children", "", "children.item_id = tags.id") + + addHierarchicalConditionClauses(f, tags, "children", "root_id") + } + + if len(tags.Excludes) > 0 { + var args []interface{} + for _, val := range tags.Excludes { + args = append(args, val) + } + + depthVal := 0 + if tags.Depth != nil { + depthVal = *tags.Depth + } + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + query := `children2 AS ( + SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Excludes)) + ` + UNION + SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children2 ON item_id = child_id ` + depthCondition + ` + )` + + f.addRecursiveWith(query, args...) + + f.addLeftJoin("children2", "", "children2.item_id = tags.id") + + addHierarchicalConditionClauses(f, models.HierarchicalMultiCriterionInput{ + Value: tags.Excludes, + Depth: tags.Depth, + Modifier: models.CriterionModifierExcludes, + }, "children2", "root_id") + } + } + } +} + +func (qb *tagFilterHandler) parentCountCriterionHandler(parentCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if parentCount != nil { + f.addLeftJoin("tags_relations", "parents_count", "parents_count.child_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct parents_count.parent_id)", *parentCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *tagFilterHandler) childCountCriterionHandler(childCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if childCount != nil { + f.addLeftJoin("tags_relations", "children_count", "children_count.parent_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct children_count.child_id)", *childCount) + + f.addHaving(clause, args...) + } + } +} diff --git a/pkg/sqlite/tx.go b/pkg/sqlite/tx.go index 64df163a0..a2e272aa9 100644 --- a/pkg/sqlite/tx.go +++ b/pkg/sqlite/tx.go @@ -35,7 +35,9 @@ func logSQL(start time.Time, query string, args ...interface{}) { } } -type dbWrapper struct{} +type dbWrapperType struct{} + +var dbWrapper = dbWrapperType{} func sqlError(err error, sql string, args ...interface{}) error { if err == nil { @@ -45,7 +47,7 @@ func sqlError(err error, sql string, args ...interface{}) error { return fmt.Errorf("error executing `%s` [%v]: %w", sql, args, err) } -func (*dbWrapper) Get(ctx context.Context, dest interface{}, query string, args ...interface{}) error { +func (*dbWrapperType) Get(ctx context.Context, dest interface{}, query string, args ...interface{}) error { tx, err := getDBReader(ctx) if err != nil { return sqlError(err, query, args...) @@ -58,7 +60,7 @@ func (*dbWrapper) Get(ctx context.Context, dest interface{}, query string, args return sqlError(err, query, args...) } -func (*dbWrapper) Select(ctx context.Context, dest interface{}, query string, args ...interface{}) error { +func (*dbWrapperType) Select(ctx context.Context, dest interface{}, query string, args ...interface{}) error { tx, err := getDBReader(ctx) if err != nil { return sqlError(err, query, args...) @@ -71,7 +73,7 @@ func (*dbWrapper) Select(ctx context.Context, dest interface{}, query string, ar return sqlError(err, query, args...) } -func (*dbWrapper) Queryx(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) { +func (*dbWrapperType) Queryx(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) { tx, err := getDBReader(ctx) if err != nil { return nil, sqlError(err, query, args...) @@ -84,7 +86,7 @@ func (*dbWrapper) Queryx(ctx context.Context, query string, args ...interface{}) return ret, sqlError(err, query, args...) } -func (*dbWrapper) QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) { +func (*dbWrapperType) QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) { tx, err := getDBReader(ctx) if err != nil { return nil, sqlError(err, query, args...) @@ -97,7 +99,7 @@ func (*dbWrapper) QueryxContext(ctx context.Context, query string, args ...inter return ret, sqlError(err, query, args...) } -func (*dbWrapper) NamedExec(ctx context.Context, query string, arg interface{}) (sql.Result, error) { +func (*dbWrapperType) NamedExec(ctx context.Context, query string, arg interface{}) (sql.Result, error) { tx, err := getTx(ctx) if err != nil { return nil, sqlError(err, query, arg) @@ -110,7 +112,7 @@ func (*dbWrapper) NamedExec(ctx context.Context, query string, arg interface{}) return ret, sqlError(err, query, arg) } -func (*dbWrapper) Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { +func (*dbWrapperType) Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { tx, err := getTx(ctx) if err != nil { return nil, sqlError(err, query, args...) @@ -124,7 +126,7 @@ func (*dbWrapper) Exec(ctx context.Context, query string, args ...interface{}) ( } // Prepare creates a prepared statement. -func (*dbWrapper) Prepare(ctx context.Context, query string, args ...interface{}) (*stmt, error) { +func (*dbWrapperType) Prepare(ctx context.Context, query string, args ...interface{}) (*stmt, error) { tx, err := getTx(ctx) if err != nil { return nil, sqlError(err, query, args...) @@ -142,7 +144,7 @@ func (*dbWrapper) Prepare(ctx context.Context, query string, args ...interface{} }, nil } -func (*dbWrapper) ExecStmt(ctx context.Context, stmt *stmt, args ...interface{}) (sql.Result, error) { +func (*dbWrapperType) ExecStmt(ctx context.Context, stmt *stmt, args ...interface{}) (sql.Result, error) { _, err := getTx(ctx) if err != nil { return nil, sqlError(err, stmt.query, args...)