diff --git a/pkg/models/filter.go b/pkg/models/filter.go index e0f9b7a54..e9ddf7ab3 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -135,6 +135,17 @@ type HierarchicalMultiCriterionInput struct { Excludes []string `json:"excludes"` } +func (i HierarchicalMultiCriterionInput) CombineExcludes() HierarchicalMultiCriterionInput { + ii := i + if ii.Modifier == CriterionModifierExcludes { + ii.Modifier = CriterionModifierIncludesAll + ii.Excludes = append(ii.Excludes, ii.Value...) + ii.Value = nil + } + + return ii +} + type MultiCriterionInput struct { Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index d670dc1a7..5934b2c99 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -9,7 +9,6 @@ import ( "strconv" "strings" - "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/pkg/models" @@ -694,6 +693,8 @@ func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInp }) 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) @@ -830,6 +831,33 @@ func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCrit } } +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 @@ -838,12 +866,20 @@ type hierarchicalMultiCriterionHandlerBuilder struct { foreignFK string parentFK string + childFK string relationsTable string } -func getHierarchicalValues(ctx context.Context, tx dbWrapper, values []string, table, relationsTable, parentFK string, depth *int) 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 @@ -865,7 +901,7 @@ func getHierarchicalValues(ctx context.Context, tx dbWrapper, values []string, t } if valid { - return "VALUES" + strings.Join(valuesClauses, ",") + return "VALUES" + strings.Join(valuesClauses, ","), nil } } @@ -885,13 +921,14 @@ func getHierarchicalValues(ctx context.Context, tx dbWrapper, values []string, t "inBinding": getInBinding(inCount), "recursiveSelect": "", "parentFK": parentFK, + "childFK": childFK, "depthCondition": depthCondition, "unionClause": "", } if relationsTable != "" { - withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.child_id, depth + 1 FROM {relationsTable} AS c -INNER JOIN items as p ON c.parent_id = p.item_id + 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 @@ -916,12 +953,10 @@ WHERE id in {inBinding} var valuesClause string err := tx.Get(ctx, &valuesClause, query, args...) if err != nil { - logger.Error(err) - // return record which never matches so we don't have to handle error here - return "VALUES(NULL, NULL)" + return "", fmt.Errorf("failed to get hierarchical values: %w", err) } - return valuesClause + return valuesClause, nil } func addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { @@ -942,6 +977,12 @@ func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hierarchica // 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 { @@ -968,7 +1009,11 @@ func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hierarchica } if len(criterion.Value) > 0 { - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + 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: @@ -980,7 +1025,11 @@ func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hierarchica } if len(criterion.Excludes) > 0 { - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + 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)) } @@ -992,10 +1041,12 @@ type joinedHierarchicalMultiCriterionHandlerBuilder struct { tx dbWrapper primaryTable string + primaryKey string foreignTable string foreignFK string parentFK string + childFK string relationsTable string joinAs string @@ -1004,16 +1055,25 @@ type joinedHierarchicalMultiCriterionHandlerBuilder struct { } func (m *joinedHierarchicalMultiCriterionHandlerBuilder) addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { - if criterion.Modifier == models.CriterionModifierEquals { + 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}.id) = ?", utils.StrFormatMap{ + 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)) - } else { + case models.CriterionModifierNotEquals: + f.setError(fmt.Errorf("not equals modifier is not supported for hierarchical multi criterion input")) + default: addHierarchicalConditionClauses(f, criterion, table, idColumn) } } @@ -1024,6 +1084,15 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera // 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 @@ -1031,7 +1100,7 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera notClause = "NOT" } - f.addLeftJoin(m.joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.id", joinAlias, m.primaryFK, m.primaryTable)) + 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, @@ -1053,7 +1122,11 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera } if len(criterion.Value) > 0 { - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + 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 @@ -1065,13 +1138,17 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera "valuesClause": valuesClause, }) - f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.id", joinAlias, m.primaryFK, m.primaryTable)) + 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 := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + 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 @@ -1085,7 +1162,7 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera joinAlias2 := joinAlias + "2" - f.addLeftJoin(joinTable, joinAlias2, fmt.Sprintf("%s.%s = %s.id", joinAlias2, m.primaryFK, m.primaryTable)) + f.addLeftJoin(joinTable, joinAlias2, fmt.Sprintf("%s.%s = %s.%s", joinAlias2, m.primaryFK, m.primaryTable, primaryKey)) // modify for exclusion criterionCopy := criterion @@ -1098,6 +1175,83 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera } } +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 diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 5f5291053..2e857cc34 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -670,7 +670,7 @@ func (qb *GalleryStore) makeFilter(ctx context.Context, galleryFilter *models.Ga query.handleCriterion(ctx, galleryPerformersCriterionHandler(qb, galleryFilter.Performers)) query.handleCriterion(ctx, galleryPerformerCountCriterionHandler(qb, galleryFilter.PerformerCount)) query.handleCriterion(ctx, hasChaptersCriterionHandler(galleryFilter.HasChapters)) - query.handleCriterion(ctx, galleryStudioCriterionHandler(qb, galleryFilter.Studios)) + 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)) @@ -968,51 +968,12 @@ func hasChaptersCriterionHandler(hasChapters *string) criterionHandlerFunc { } } -func galleryStudioCriterionHandler(qb *GalleryStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := hierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: galleryTable, - foreignTable: studioTable, - foreignFK: studioIDColumn, - parentFK: "parent_id", - } - - return h.handler(studios) -} - -func galleryPerformerTagsCriterionHandler(qb *GalleryStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if tags != nil { - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("performers_galleries", "", "galleries.id = performers_galleries.gallery_id") - f.addLeftJoin("performers_tags", "", "performers_galleries.performer_id = performers_tags.performer_id") - - f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause)) - return - } - - if len(tags.Value) == 0 { - return - } - - valuesClause := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) - - f.addWith(`performer_tags AS ( -SELECT pg.gallery_id, t.column1 AS root_tag_id FROM performers_galleries pg -INNER JOIN performers_tags pt ON pt.performer_id = pg.performer_id -INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id -)`) - - f.addLeftJoin("performer_tags", "", "performer_tags.gallery_id = galleries.id") - - addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") - } +func galleryPerformerTagsCriterionHandler(qb *GalleryStore, tags *models.HierarchicalMultiCriterionInput) criterionHandler { + return &joinedPerformerTagsHandler{ + criterion: tags, + primaryTable: galleryTable, + joinTable: performersGalleriesTable, + joinPrimaryKey: galleryIDColumn, } } diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 6d145cb1b..bad75d035 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -1945,154 +1945,369 @@ func TestGalleryQueryIsMissingDate(t *testing.T) { } func TestGalleryQueryPerformers(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Gallery - performerCriterion := models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdxWithGallery]), - strconv.Itoa(performerIDs[performerIdx1WithGallery]), + tests := []struct { + name string + filter models.MultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdxWithGallery]), + strconv.Itoa(performerIDs[performerIdx1WithGallery]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - galleryFilter := models.GalleryFilterType{ - Performers: &performerCriterion, - } - - galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) - - assert.Len(t, galleries, 2) - - // ensure ids are correct - for _, gallery := range galleries { - assert.True(t, gallery.ID == galleryIDs[galleryIdxWithPerformer] || gallery.ID == galleryIDs[galleryIdxWithTwoPerformers]) - } - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithGallery]), - strconv.Itoa(performerIDs[performerIdx2WithGallery]), + []int{ + galleryIdxWithPerformer, + galleryIdxWithTwoPerformers, }, - Modifier: models.CriterionModifierIncludesAll, - } - - galleries = queryGallery(ctx, t, sqb, &galleryFilter, nil) - - assert.Len(t, galleries, 1) - assert.Equal(t, galleryIDs[galleryIdxWithTwoPerformers], galleries[0].ID) - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithGallery]), + []int{ + galleryIdxWithImage, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes all", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdx1WithGallery]), + strconv.Itoa(performerIDs[performerIdx2WithGallery]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + galleryIdxWithTwoPerformers, + }, + []int{ + galleryIdxWithPerformer, + }, + false, + }, + { + "excludes", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[performerIdx1WithGallery])}, + }, + nil, + []int{galleryIdxWithTwoPerformers}, + false, + }, + { + "is null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{galleryIdxWithTag}, + []int{ + galleryIdxWithPerformer, + galleryIdxWithTwoPerformers, + galleryIdxWithPerformerTwoTags, + }, + false, + }, + { + "not null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + galleryIdxWithPerformer, + galleryIdxWithTwoPerformers, + galleryIdxWithPerformerTwoTags, + }, + []int{galleryIdxWithTag}, + false, + }, + { + "equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithGallery]), + strconv.Itoa(tagIDs[performerIdx2WithGallery]), + }, + }, + []int{galleryIdxWithTwoPerformers}, + []int{ + galleryIdxWithThreePerformers, + }, + false, + }, + { + "not equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithGallery]), + strconv.Itoa(tagIDs[performerIdx2WithGallery]), + }, + }, + nil, + nil, + true, + }, + } - q := getGalleryStringValue(galleryIdxWithTwoPerformers, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) + results, _, err := db.Gallery.Query(ctx, &models.GalleryFilterType{ + Performers: &tt.filter, + }, nil) + if (err != nil) != tt.wantErr { + t.Errorf("GalleryStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - return nil - }) + ids := galleriesToIDs(results) + + include := indexesToIDs(galleryIDs, tt.includeIdxs) + exclude := indexesToIDs(galleryIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } } func TestGalleryQueryTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Gallery - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithGallery]), - strconv.Itoa(tagIDs[tagIdx1WithGallery]), + tests := []struct { + name string + filter models.HierarchicalMultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithGallery]), + strconv.Itoa(tagIDs[tagIdx1WithGallery]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - galleryFilter := models.GalleryFilterType{ - Tags: &tagCriterion, - } - - galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) - assert.Len(t, galleries, 2) - - // ensure ids are correct - for _, gallery := range galleries { - assert.True(t, gallery.ID == galleryIDs[galleryIdxWithTag] || gallery.ID == galleryIDs[galleryIdxWithTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithGallery]), - strconv.Itoa(tagIDs[tagIdx2WithGallery]), + []int{ + galleryIdxWithTag, + galleryIdxWithTwoTags, }, - Modifier: models.CriterionModifierIncludesAll, - } - - galleries = queryGallery(ctx, t, sqb, &galleryFilter, nil) - - assert.Len(t, galleries, 1) - assert.Equal(t, galleryIDs[galleryIdxWithTwoTags], galleries[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithGallery]), + []int{ + galleryIdxWithImage, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes all", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithGallery]), + strconv.Itoa(tagIDs[tagIdx2WithGallery]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + galleryIdxWithTwoTags, + }, + []int{ + galleryIdxWithTag, + }, + false, + }, + { + "excludes", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx1WithGallery])}, + }, + nil, + []int{galleryIdxWithTwoTags}, + false, + }, + { + "is null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{galleryIdx1WithPerformer}, + []int{ + galleryIdxWithTag, + galleryIdxWithTwoTags, + galleryIdxWithThreeTags, + }, + false, + }, + { + "not null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + galleryIdxWithTag, + galleryIdxWithTwoTags, + galleryIdxWithThreeTags, + }, + []int{galleryIdx1WithPerformer}, + false, + }, + { + "equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithGallery]), + strconv.Itoa(tagIDs[tagIdx2WithGallery]), + }, + }, + []int{galleryIdxWithTwoTags}, + []int{ + galleryIdxWithThreeTags, + }, + false, + }, + { + "not equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithGallery]), + strconv.Itoa(tagIDs[tagIdx2WithGallery]), + }, + }, + nil, + nil, + true, + }, + } - q := getGalleryStringValue(galleryIdxWithTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) + results, _, err := db.Gallery.Query(ctx, &models.GalleryFilterType{ + Tags: &tt.filter, + }, nil) + if (err != nil) != tt.wantErr { + t.Errorf("GalleryStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - return nil - }) + ids := galleriesToIDs(results) + + include := indexesToIDs(imageIDs, tt.includeIdxs) + exclude := indexesToIDs(imageIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } } func TestGalleryQueryStudio(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Gallery - studioCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(studioIDs[studioIdxWithGallery]), + tests := []struct { + name string + q string + studioCriterion models.HierarchicalMultiCriterionInput + expectedIDs []int + wantErr bool + }{ + { + "includes", + "", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGallery]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - galleryFilter := models.GalleryFilterType{ - Studios: &studioCriterion, - } - - galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) - - assert.Len(t, galleries, 1) - - // ensure id is correct - assert.Equal(t, galleryIDs[galleryIdxWithStudio], galleries[0].ID) - - studioCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(studioIDs[studioIdxWithGallery]), + []int{galleryIDs[galleryIdxWithStudio]}, + false, + }, + { + "excludes", + getGalleryStringValue(galleryIdxWithStudio, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGallery]), + }, + Modifier: models.CriterionModifierExcludes, }, - Modifier: models.CriterionModifierExcludes, - } + []int{}, + false, + }, + { + "excludes includes null", + getGalleryStringValue(galleryIdxWithImage, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGallery]), + }, + Modifier: models.CriterionModifierExcludes, + }, + []int{galleryIDs[galleryIdxWithImage]}, + false, + }, + { + "equals", + "", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGallery]), + }, + Modifier: models.CriterionModifierEquals, + }, + []int{galleryIDs[galleryIdxWithStudio]}, + false, + }, + { + "not equals", + getGalleryStringValue(galleryIdxWithStudio, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGallery]), + }, + Modifier: models.CriterionModifierNotEquals, + }, + []int{}, + false, + }, + } - q := getGalleryStringValue(galleryIdxWithStudio, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + qb := db.Gallery - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + studioCriterion := tt.studioCriterion - return nil - }) + galleryFilter := models.GalleryFilterType{ + Studios: &studioCriterion, + } + + var findFilter *models.FindFilterType + if tt.q != "" { + findFilter = &models.FindFilterType{ + Q: &tt.q, + } + } + + gallerys := queryGallery(ctx, t, qb, &galleryFilter, findFilter) + + assert.ElementsMatch(t, galleriesToIDs(gallerys), tt.expectedIDs) + }) + } } func TestGalleryQueryStudioDepth(t *testing.T) { @@ -2157,81 +2372,198 @@ func TestGalleryQueryStudioDepth(t *testing.T) { } func TestGalleryQueryPerformerTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Gallery - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithPerformer]), - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + allDepth := -1 + + tests := []struct { + name string + findFilter *models.FindFilterType + filter *models.GalleryFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithPerformer]), + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierIncludes, + }, }, - Modifier: models.CriterionModifierIncludes, - } - - galleryFilter := models.GalleryFilterType{ - PerformerTags: &tagCriterion, - } - - galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) - assert.Len(t, galleries, 2) - - // ensure ids are correct - for _, gallery := range galleries { - assert.True(t, gallery.ID == galleryIDs[galleryIdxWithPerformerTag] || gallery.ID == galleryIDs[galleryIdxWithPerformerTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), - strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + []int{ + galleryIdxWithPerformerTag, + galleryIdxWithPerformerTwoTags, + galleryIdxWithTwoPerformerTag, }, - Modifier: models.CriterionModifierIncludesAll, - } - - galleries = queryGallery(ctx, t, sqb, &galleryFilter, nil) - - assert.Len(t, galleries, 1) - assert.Equal(t, galleryIDs[galleryIdxWithPerformerTwoTags], galleries[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + []int{ + galleryIdxWithPerformer, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes sub-tags", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierIncludes, + }, + }, + []int{ + galleryIdxWithPerformerParentTag, + }, + []int{ + galleryIdxWithPerformer, + galleryIdxWithPerformerTag, + galleryIdxWithPerformerTwoTags, + galleryIdxWithTwoPerformerTag, + }, + false, + }, + { + "includes all", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + }, + []int{ + galleryIdxWithPerformerTwoTags, + }, + []int{ + galleryIdxWithPerformer, + galleryIdxWithPerformerTag, + galleryIdxWithTwoPerformerTag, + }, + false, + }, + { + "excludes performer tag tagIdx2WithPerformer", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx2WithPerformer])}, + }, + }, + nil, + []int{galleryIdxWithTwoPerformerTag}, + false, + }, + { + "excludes sub-tags", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierExcludes, + }, + }, + []int{ + galleryIdxWithPerformer, + galleryIdxWithPerformerTag, + galleryIdxWithPerformerTwoTags, + galleryIdxWithTwoPerformerTag, + }, + []int{ + galleryIdxWithPerformerParentTag, + }, + false, + }, + { + "is null", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + }, + []int{galleryIdx1WithImage}, + []int{galleryIdxWithPerformerTag}, + false, + }, + { + "not null", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + }, + []int{galleryIdxWithPerformerTag}, + []int{galleryIdx1WithImage}, + false, + }, + { + "equals", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + { + "not equals", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + } - q := getGalleryStringValue(galleryIdxWithPerformerTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) + results, _, err := db.Gallery.Query(ctx, tt.filter, tt.findFilter) + if (err != nil) != tt.wantErr { + t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - tagCriterion = models.HierarchicalMultiCriterionInput{ - Modifier: models.CriterionModifierIsNull, - } - q = getGalleryStringValue(galleryIdx1WithImage, titleField) + ids := galleriesToIDs(results) - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 1) - assert.Equal(t, galleryIDs[galleryIdx1WithImage], galleries[0].ID) + include := indexesToIDs(galleryIDs, tt.includeIdxs) + exclude := indexesToIDs(galleryIDs, tt.excludeIdxs) - q = getGalleryStringValue(galleryIdxWithPerformerTag, titleField) - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) - - tagCriterion.Modifier = models.CriterionModifierNotNull - - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 1) - assert.Equal(t, galleryIDs[galleryIdxWithPerformerTag], galleries[0].ID) - - q = getGalleryStringValue(galleryIdx1WithImage, titleField) - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) - - return nil - }) + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } } func TestGalleryQueryTagCount(t *testing.T) { diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index d42de9f85..9dee5ed28 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -669,7 +669,7 @@ func (qb *ImageStore) makeFilter(ctx context.Context, imageFilter *models.ImageF query.handleCriterion(ctx, imageGalleriesCriterionHandler(qb, imageFilter.Galleries)) query.handleCriterion(ctx, imagePerformersCriterionHandler(qb, imageFilter.Performers)) query.handleCriterion(ctx, imagePerformerCountCriterionHandler(qb, imageFilter.PerformerCount)) - query.handleCriterion(ctx, imageStudioCriterionHandler(qb, imageFilter.Studios)) + query.handleCriterion(ctx, studioCriterionHandler(imageTable, imageFilter.Studios)) query.handleCriterion(ctx, imagePerformerTagsCriterionHandler(qb, imageFilter.PerformerTags)) query.handleCriterion(ctx, imagePerformerFavoriteCriterionHandler(imageFilter.PerformerFavorite)) query.handleCriterion(ctx, timestampCriterionHandler(imageFilter.CreatedAt, "images.created_at")) @@ -946,51 +946,12 @@ GROUP BY performers_images.image_id HAVING SUM(performers.favorite) = 0)`, "nofa } } -func imageStudioCriterionHandler(qb *ImageStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := hierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: imageTable, - foreignTable: studioTable, - foreignFK: studioIDColumn, - parentFK: "parent_id", - } - - return h.handler(studios) -} - -func imagePerformerTagsCriterionHandler(qb *ImageStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if tags != nil { - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("performers_images", "", "images.id = performers_images.image_id") - f.addLeftJoin("performers_tags", "", "performers_images.performer_id = performers_tags.performer_id") - - f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause)) - return - } - - if len(tags.Value) == 0 { - return - } - - valuesClause := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) - - f.addWith(`performer_tags AS ( -SELECT pi.image_id, t.column1 AS root_tag_id FROM performers_images pi -INNER JOIN performers_tags pt ON pt.performer_id = pi.performer_id -INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id -)`) - - f.addLeftJoin("performer_tags", "", "performer_tags.image_id = images.id") - - addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") - } +func imagePerformerTagsCriterionHandler(qb *ImageStore, 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 1a0fceb29..3ec159877 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -2124,203 +2124,369 @@ func TestImageQueryGallery(t *testing.T) { } func TestImageQueryPerformers(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Image - performerCriterion := models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdxWithImage]), - strconv.Itoa(performerIDs[performerIdx1WithImage]), + tests := []struct { + name string + filter models.MultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdxWithImage]), + strconv.Itoa(performerIDs[performerIdx1WithImage]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - imageFilter := models.ImageFilterType{ - Performers: &performerCriterion, - } - - images := queryImages(ctx, t, sqb, &imageFilter, nil) - assert.Len(t, images, 2) - - // ensure ids are correct - for _, image := range images { - assert.True(t, image.ID == imageIDs[imageIdxWithPerformer] || image.ID == imageIDs[imageIdxWithTwoPerformers]) - } - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithImage]), - strconv.Itoa(performerIDs[performerIdx2WithImage]), + []int{ + imageIdxWithPerformer, + imageIdxWithTwoPerformers, }, - Modifier: models.CriterionModifierIncludesAll, - } - - images = queryImages(ctx, t, sqb, &imageFilter, nil) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithTwoPerformers], images[0].ID) - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithImage]), + []int{ + imageIdxWithGallery, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes all", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdx1WithImage]), + strconv.Itoa(performerIDs[performerIdx2WithImage]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + imageIdxWithTwoPerformers, + }, + []int{ + imageIdxWithPerformer, + }, + false, + }, + { + "excludes", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[performerIdx1WithImage])}, + }, + nil, + []int{imageIdxWithTwoPerformers}, + false, + }, + { + "is null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{imageIdxWithTag}, + []int{ + imageIdxWithPerformer, + imageIdxWithTwoPerformers, + imageIdxWithPerformerTwoTags, + }, + false, + }, + { + "not null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + imageIdxWithPerformer, + imageIdxWithTwoPerformers, + imageIdxWithPerformerTwoTags, + }, + []int{imageIdxWithTag}, + false, + }, + { + "equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithImage]), + strconv.Itoa(tagIDs[performerIdx2WithImage]), + }, + }, + []int{imageIdxWithTwoPerformers}, + []int{ + imageIdxWithThreePerformers, + }, + false, + }, + { + "not equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithImage]), + strconv.Itoa(tagIDs[performerIdx2WithImage]), + }, + }, + nil, + nil, + true, + }, + } - q := getImageStringValue(imageIdxWithTwoPerformers, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) + results, err := db.Image.Query(ctx, models.ImageQueryOptions{ + ImageFilter: &models.ImageFilterType{ + Performers: &tt.filter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - performerCriterion = models.MultiCriterionInput{ - Modifier: models.CriterionModifierIsNull, - } - q = getImageStringValue(imageIdxWithGallery, titleField) + include := indexesToIDs(imageIDs, tt.includeIdxs) + exclude := indexesToIDs(imageIDs, tt.excludeIdxs) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithGallery], images[0].ID) - - q = getImageStringValue(imageIdxWithPerformerTag, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) - - performerCriterion.Modifier = models.CriterionModifierNotNull - - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithPerformerTag], images[0].ID) - - q = getImageStringValue(imageIdxWithGallery, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) - - return nil - }) + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestImageQueryTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Image - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithImage]), - strconv.Itoa(tagIDs[tagIdx1WithImage]), + tests := []struct { + name string + filter models.HierarchicalMultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithImage]), + strconv.Itoa(tagIDs[tagIdx1WithImage]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - imageFilter := models.ImageFilterType{ - Tags: &tagCriterion, - } - - images := queryImages(ctx, t, sqb, &imageFilter, nil) - assert.Len(t, images, 2) - - // ensure ids are correct - for _, image := range images { - assert.True(t, image.ID == imageIDs[imageIdxWithTag] || image.ID == imageIDs[imageIdxWithTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithImage]), - strconv.Itoa(tagIDs[tagIdx2WithImage]), + []int{ + imageIdxWithTag, + imageIdxWithTwoTags, }, - Modifier: models.CriterionModifierIncludesAll, - } - - images = queryImages(ctx, t, sqb, &imageFilter, nil) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithTwoTags], images[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithImage]), + []int{ + imageIdxWithGallery, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes all", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithImage]), + strconv.Itoa(tagIDs[tagIdx2WithImage]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + imageIdxWithTwoTags, + }, + []int{ + imageIdxWithTag, + }, + false, + }, + { + "excludes", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx1WithImage])}, + }, + nil, + []int{imageIdxWithTwoTags}, + false, + }, + { + "is null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{imageIdx1WithPerformer}, + []int{ + imageIdxWithTag, + imageIdxWithTwoTags, + imageIdxWithThreeTags, + }, + false, + }, + { + "not null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + imageIdxWithTag, + imageIdxWithTwoTags, + imageIdxWithThreeTags, + }, + []int{imageIdx1WithPerformer}, + false, + }, + { + "equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithImage]), + strconv.Itoa(tagIDs[tagIdx2WithImage]), + }, + }, + []int{imageIdxWithTwoTags}, + []int{ + imageIdxWithThreeTags, + }, + false, + }, + { + "not equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithImage]), + strconv.Itoa(tagIDs[tagIdx2WithImage]), + }, + }, + nil, + nil, + true, + }, + } - q := getImageStringValue(imageIdxWithTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) + results, err := db.Image.Query(ctx, models.ImageQueryOptions{ + ImageFilter: &models.ImageFilterType{ + Tags: &tt.filter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - tagCriterion = models.HierarchicalMultiCriterionInput{ - Modifier: models.CriterionModifierIsNull, - } - q = getImageStringValue(imageIdxWithGallery, titleField) + include := indexesToIDs(imageIDs, tt.includeIdxs) + exclude := indexesToIDs(imageIDs, tt.excludeIdxs) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithGallery], images[0].ID) - - q = getImageStringValue(imageIdxWithTag, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) - - tagCriterion.Modifier = models.CriterionModifierNotNull - - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithTag], images[0].ID) - - q = getImageStringValue(imageIdxWithGallery, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) - - return nil - }) + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestImageQueryStudio(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Image - studioCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(studioIDs[studioIdxWithImage]), + tests := []struct { + name string + q string + studioCriterion models.HierarchicalMultiCriterionInput + expectedIDs []int + wantErr bool + }{ + { + "includes", + "", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithImage]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - imageFilter := models.ImageFilterType{ - Studios: &studioCriterion, - } - - images, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, nil) - if err != nil { - t.Errorf("Error querying image: %s", err.Error()) - } - - assert.Len(t, images, 1) - - // ensure id is correct - assert.Equal(t, imageIDs[imageIdxWithStudio], images[0].ID) - - studioCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(studioIDs[studioIdxWithImage]), + []int{imageIDs[imageIdxWithStudio]}, + false, + }, + { + "excludes", + getImageStringValue(imageIdxWithStudio, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithImage]), + }, + Modifier: models.CriterionModifierExcludes, }, - Modifier: models.CriterionModifierExcludes, - } + []int{}, + false, + }, + { + "excludes includes null", + getImageStringValue(imageIdxWithGallery, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithImage]), + }, + Modifier: models.CriterionModifierExcludes, + }, + []int{imageIDs[imageIdxWithGallery]}, + false, + }, + { + "equals", + "", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithImage]), + }, + Modifier: models.CriterionModifierEquals, + }, + []int{imageIDs[imageIdxWithStudio]}, + false, + }, + { + "not equals", + getImageStringValue(imageIdxWithStudio, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithImage]), + }, + Modifier: models.CriterionModifierNotEquals, + }, + []int{}, + false, + }, + } - q := getImageStringValue(imageIdxWithStudio, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + qb := db.Image - images, _, err = queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter) - if err != nil { - t.Errorf("Error querying image: %s", err.Error()) - } - assert.Len(t, images, 0) + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + studioCriterion := tt.studioCriterion - return nil - }) + imageFilter := models.ImageFilterType{ + Studios: &studioCriterion, + } + + var findFilter *models.FindFilterType + if tt.q != "" { + findFilter = &models.FindFilterType{ + Q: &tt.q, + } + } + + images := queryImages(ctx, t, qb, &imageFilter, findFilter) + + assert.ElementsMatch(t, imagesToIDs(images), tt.expectedIDs) + }) + } } func TestImageQueryStudioDepth(t *testing.T) { @@ -2394,81 +2560,201 @@ func queryImages(ctx context.Context, t *testing.T, sqb models.ImageReader, imag } func TestImageQueryPerformerTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Image - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithPerformer]), - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + allDepth := -1 + + tests := []struct { + name string + findFilter *models.FindFilterType + filter *models.ImageFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithPerformer]), + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierIncludes, + }, }, - Modifier: models.CriterionModifierIncludes, - } - - imageFilter := models.ImageFilterType{ - PerformerTags: &tagCriterion, - } - - images := queryImages(ctx, t, sqb, &imageFilter, nil) - assert.Len(t, images, 2) - - // ensure ids are correct - for _, image := range images { - assert.True(t, image.ID == imageIDs[imageIdxWithPerformerTag] || image.ID == imageIDs[imageIdxWithPerformerTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), - strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + []int{ + imageIdxWithPerformerTag, + imageIdxWithPerformerTwoTags, + imageIdxWithTwoPerformerTag, }, - Modifier: models.CriterionModifierIncludesAll, - } - - images = queryImages(ctx, t, sqb, &imageFilter, nil) - - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithPerformerTwoTags], images[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + []int{ + imageIdxWithPerformer, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes sub-tags", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierIncludes, + }, + }, + []int{ + imageIdxWithPerformerParentTag, + }, + []int{ + imageIdxWithPerformer, + imageIdxWithPerformerTag, + imageIdxWithPerformerTwoTags, + imageIdxWithTwoPerformerTag, + }, + false, + }, + { + "includes all", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + }, + []int{ + imageIdxWithPerformerTwoTags, + }, + []int{ + imageIdxWithPerformer, + imageIdxWithPerformerTag, + imageIdxWithTwoPerformerTag, + }, + false, + }, + { + "excludes performer tag tagIdx2WithPerformer", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx2WithPerformer])}, + }, + }, + nil, + []int{imageIdxWithTwoPerformerTag}, + false, + }, + { + "excludes sub-tags", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierExcludes, + }, + }, + []int{ + imageIdxWithPerformer, + imageIdxWithPerformerTag, + imageIdxWithPerformerTwoTags, + imageIdxWithTwoPerformerTag, + }, + []int{ + imageIdxWithPerformerParentTag, + }, + false, + }, + { + "is null", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + }, + []int{imageIdxWithGallery}, + []int{imageIdxWithPerformerTag}, + false, + }, + { + "not null", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + }, + []int{imageIdxWithPerformerTag}, + []int{imageIdxWithGallery}, + false, + }, + { + "equals", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + { + "not equals", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + } - q := getImageStringValue(imageIdxWithPerformerTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) + results, err := db.Image.Query(ctx, models.ImageQueryOptions{ + ImageFilter: tt.filter, + QueryOptions: models.QueryOptions{ + FindFilter: tt.findFilter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - tagCriterion = models.HierarchicalMultiCriterionInput{ - Modifier: models.CriterionModifierIsNull, - } - q = getImageStringValue(imageIdxWithGallery, titleField) + include := indexesToIDs(imageIDs, tt.includeIdxs) + exclude := indexesToIDs(imageIDs, tt.excludeIdxs) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithGallery], images[0].ID) - - q = getImageStringValue(imageIdxWithPerformerTag, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) - - tagCriterion.Modifier = models.CriterionModifierNotNull - - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithPerformerTag], images[0].ID) - - q = getImageStringValue(imageIdxWithGallery, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) - - return nil - }) + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestImageQueryTagCount(t *testing.T) { @@ -2587,7 +2873,7 @@ func TestImageQuerySorting(t *testing.T) { "date", models.SortDirectionEnumDesc, imageIdxWithTwoGalleries, - imageIdxWithGrandChildStudio, + imageIdxWithPerformerParentTag, }, } diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index 7ff13c2e3..3bc273cbf 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -176,7 +176,7 @@ func (qb *movieQueryBuilder) makeFilter(ctx context.Context, movieFilter *models 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, movieStudioCriterionHandler(qb, movieFilter.Studios)) + 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")) @@ -239,19 +239,6 @@ func movieIsMissingCriterionHandler(qb *movieQueryBuilder, isMissing *string) cr } } -func movieStudioCriterionHandler(qb *movieQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := hierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: movieTable, - foreignTable: studioTable, - foreignFK: studioIDColumn, - parentFK: "parent_id", - } - - return h.handler(studios) -} - func moviePerformersCriterionHandler(qb *movieQueryBuilder, performers *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performers != nil { diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index d1079eac0..f4f11e684 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -908,7 +908,11 @@ func performerStudiosCriterionHandler(qb *PerformerStore, studios *models.Hierar } const derivedPerformerStudioTable = "performer_studio" - valuesClause := getHierarchicalValues(ctx, qb.tx, studios.Value, studioTable, "", "parent_id", studios.Depth) + 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} diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index a874f3967..89605ac89 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -513,12 +513,13 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { performerIDs[performerIdxWithTwoTags], clearPerformerPartial(), models.Performer{ - ID: performerIDs[performerIdxWithTwoTags], - Name: getPerformerStringValue(performerIdxWithTwoTags, "Name"), - Favorite: true, - Aliases: models.NewRelatedStrings([]string{}), - TagIDs: models.NewRelatedIDs([]int{}), - StashIDs: models.NewRelatedStashIDs([]models.StashID{}), + ID: performerIDs[performerIdxWithTwoTags], + Name: getPerformerStringValue(performerIdxWithTwoTags, "Name"), + Favorite: getPerformerBoolValue(performerIdxWithTwoTags), + Aliases: models.NewRelatedStrings([]string{}), + TagIDs: models.NewRelatedIDs([]int{}), + StashIDs: models.NewRelatedStashIDs([]models.StashID{}), + IgnoreAutoTag: getIgnoreAutoTag(performerIdxWithTwoTags), }, false, }, @@ -1904,10 +1905,10 @@ func TestPerformerQuerySortScenesCount(t *testing.T) { assert.True(t, len(performers) > 0) - // first performer should be performerIdxWithTwoScenes + // first performer should be performerIdx1WithScene firstPerformer := performers[0] - assert.Equal(t, performerIDs[performerIdxWithTwoScenes], firstPerformer.ID) + assert.Equal(t, performerIDs[performerIdx1WithScene], firstPerformer.ID) // sort in ascending order direction = models.SortDirectionEnumAsc @@ -1920,7 +1921,7 @@ func TestPerformerQuerySortScenesCount(t *testing.T) { assert.True(t, len(performers) > 0) lastPerformer := performers[len(performers)-1] - assert.Equal(t, performerIDs[performerIdxWithTwoScenes], lastPerformer.ID) + assert.Equal(t, performerIDs[performerIdxWithTag], lastPerformer.ID) return nil }) @@ -2060,7 +2061,7 @@ func TestPerformerStore_FindByStashIDStatus(t *testing.T) { name: "!hasStashID", hasStashID: false, stashboxEndpoint: getPerformerStringValue(performerIdxWithScene, "endpoint"), - include: []int{performerIdxWithImage}, + include: []int{performerIdxWithTwoScenes}, exclude: []int{performerIdx2WithScene}, wantErr: false, }, diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 1a735bcd2..1fe5bcdb0 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -959,7 +959,7 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF query.handleCriterion(ctx, sceneTagCountCriterionHandler(qb, sceneFilter.TagCount)) query.handleCriterion(ctx, scenePerformersCriterionHandler(qb, sceneFilter.Performers)) query.handleCriterion(ctx, scenePerformerCountCriterionHandler(qb, sceneFilter.PerformerCount)) - query.handleCriterion(ctx, sceneStudioCriterionHandler(qb, sceneFilter.Studios)) + query.handleCriterion(ctx, studioCriterionHandler(sceneTable, sceneFilter.Studios)) query.handleCriterion(ctx, sceneMoviesCriterionHandler(qb, sceneFilter.Movies)) query.handleCriterion(ctx, scenePerformerTagsCriterionHandler(qb, sceneFilter.PerformerTags)) query.handleCriterion(ctx, scenePerformerFavoriteCriterionHandler(sceneFilter.PerformerFavorite)) @@ -1352,19 +1352,6 @@ func scenePerformerAgeCriterionHandler(performerAge *models.IntCriterionInput) c } } -func sceneStudioCriterionHandler(qb *SceneStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := hierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: sceneTable, - foreignTable: studioTable, - foreignFK: studioIDColumn, - parentFK: "parent_id", - } - - return h.handler(studios) -} - func sceneMoviesCriterionHandler(qb *SceneStore, movies *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { qb.moviesRepository().join(f, "", "scenes.id") @@ -1374,38 +1361,12 @@ func sceneMoviesCriterionHandler(qb *SceneStore, movies *models.MultiCriterionIn return h.handler(movies) } -func scenePerformerTagsCriterionHandler(qb *SceneStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if tags != nil { - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("performers_scenes", "", "scenes.id = performers_scenes.scene_id") - f.addLeftJoin("performers_tags", "", "performers_scenes.performer_id = performers_tags.performer_id") - - f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause)) - return - } - - if len(tags.Value) == 0 { - return - } - - valuesClause := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) - - f.addWith(`performer_tags AS ( -SELECT ps.scene_id, t.column1 AS root_tag_id FROM performers_scenes ps -INNER JOIN performers_tags pt ON pt.performer_id = ps.performer_id -INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id -)`) - - f.addLeftJoin("performer_tags", "", "performer_tags.scene_id = scenes.id") - - addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") - } +func scenePerformerTagsCriterionHandler(qb *SceneStore, tags *models.HierarchicalMultiCriterionInput) criterionHandler { + return &joinedPerformerTagsHandler{ + criterion: tags, + primaryTable: sceneTable, + joinTable: performersScenesTable, + joinPrimaryKey: sceneIDColumn, } } diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index c4ae7dda7..04eeb1e3a 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -209,7 +209,11 @@ func sceneMarkerTagsCriterionHandler(qb *sceneMarkerQueryBuilder, tags *models.H if len(tags.Value) == 0 { return } - valuesClause := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) + 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 @@ -229,32 +233,23 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = m.primary_tag_id func sceneMarkerSceneTagsCriterionHandler(qb *sceneMarkerQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if tags != nil { - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } + f.addLeftJoin("scenes_tags", "", "scene_markers.scene_id = scenes_tags.scene_id") - f.addLeftJoin("scenes_tags", "", "scene_markers.scene_id = scenes_tags.scene_id") + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + tx: qb.tx, - f.addWhere(fmt.Sprintf("scenes_tags.tag_id IS %s NULL", notClause)) - return + primaryTable: "scene_markers", + primaryKey: sceneIDColumn, + foreignTable: tagTable, + foreignFK: tagIDColumn, + + relationsTable: "tags_relations", + joinTable: "scenes_tags", + joinAs: "marker_scenes_tags", + primaryFK: sceneIDColumn, } - if len(tags.Value) == 0 { - return - } - - valuesClause := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) - - f.addWith(`scene_tags AS ( -SELECT st.scene_id, t.column1 AS root_tag_id FROM scenes_tags st -INNER JOIN (` + valuesClause + `) t ON t.column2 = st.tag_id -)`) - - f.addLeftJoin("scene_tags", "", "scene_tags.scene_id = scene_markers.scene_id") - - addHierarchicalConditionClauses(f, *tags, "scene_tags", "root_tag_id") + h.handler(tags).handle(ctx, f) } } } diff --git a/pkg/sqlite/scene_marker_test.go b/pkg/sqlite/scene_marker_test.go index 9c5ae866f..b2f7b2ee6 100644 --- a/pkg/sqlite/scene_marker_test.go +++ b/pkg/sqlite/scene_marker_test.go @@ -5,9 +5,12 @@ package sqlite_test import ( "context" + "strconv" "testing" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil/intslice" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/sqlite" "github.com/stretchr/testify/assert" ) @@ -50,7 +53,7 @@ func TestMarkerCountByTagID(t *testing.T) { t.Errorf("error calling CountByTagID: %s", err.Error()) } - assert.Equal(t, 3, markerCount) + assert.Equal(t, 4, markerCount) markerCount, err = mqb.CountByTagID(ctx, tagIDs[tagIdxWithMarkers]) @@ -151,7 +154,7 @@ func TestMarkerQuerySceneTags(t *testing.T) { } withTxn(func(ctx context.Context) error { - testTags := func(m *models.SceneMarker, markerFilter *models.SceneMarkerFilterType) { + testTags := func(t *testing.T, m *models.SceneMarker, markerFilter *models.SceneMarkerFilterType) { s, err := db.Scene.Find(ctx, int(m.SceneID.Int64)) if err != nil { t.Errorf("error getting marker tag ids: %v", err) @@ -164,11 +167,40 @@ func TestMarkerQuerySceneTags(t *testing.T) { } tagIDs := s.TagIDs.List() - if markerFilter.SceneTags.Modifier == models.CriterionModifierIsNull && len(tagIDs) > 0 { - t.Errorf("expected marker %d to have no scene tags - found %d", m.ID, len(tagIDs)) - } - if markerFilter.SceneTags.Modifier == models.CriterionModifierNotNull && len(tagIDs) == 0 { - t.Errorf("expected marker %d to have scene tags - found 0", m.ID) + values, _ := stringslice.StringSliceToIntSlice(markerFilter.SceneTags.Value) + switch markerFilter.SceneTags.Modifier { + case models.CriterionModifierIsNull: + if len(tagIDs) > 0 { + t.Errorf("expected marker %d to have no scene tags - found %d", m.ID, len(tagIDs)) + } + case models.CriterionModifierNotNull: + if len(tagIDs) == 0 { + t.Errorf("expected marker %d to have scene tags - found 0", m.ID) + } + case models.CriterionModifierIncludes: + for _, v := range values { + assert.Contains(t, tagIDs, v) + } + case models.CriterionModifierExcludes: + for _, v := range values { + assert.NotContains(t, tagIDs, v) + } + case models.CriterionModifierEquals: + for _, v := range values { + assert.Contains(t, tagIDs, v) + } + assert.Len(t, tagIDs, len(values)) + case models.CriterionModifierNotEquals: + foundAll := true + for _, v := range values { + if !intslice.IntInclude(tagIDs, v) { + foundAll = false + break + } + } + if foundAll && len(tagIDs) == len(values) { + t.Errorf("expected marker %d to have scene tags not equal to %v - found %v", m.ID, values, tagIDs) + } } } @@ -191,6 +223,70 @@ func TestMarkerQuerySceneTags(t *testing.T) { }, nil, }, + { + "includes", + &models.SceneMarkerFilterType{ + SceneTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIncludes, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx3WithScene]), + }, + }, + }, + nil, + }, + { + "includes all", + &models.SceneMarkerFilterType{ + SceneTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIncludesAll, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithScene]), + strconv.Itoa(tagIDs[tagIdx3WithScene]), + }, + }, + }, + nil, + }, + { + "equals", + &models.SceneMarkerFilterType{ + SceneTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithScene]), + strconv.Itoa(tagIDs[tagIdx3WithScene]), + }, + }, + }, + nil, + }, + // not equals not supported + // { + // "not equals", + // &models.SceneMarkerFilterType{ + // SceneTags: &models.HierarchicalMultiCriterionInput{ + // Modifier: models.CriterionModifierNotEquals, + // Value: []string{ + // strconv.Itoa(tagIDs[tagIdx2WithScene]), + // strconv.Itoa(tagIDs[tagIdx3WithScene]), + // }, + // }, + // }, + // nil, + // }, + { + "excludes", + &models.SceneMarkerFilterType{ + SceneTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIncludes, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithScene]), + }, + }, + }, + nil, + }, } for _, tc := range cases { @@ -198,7 +294,7 @@ func TestMarkerQuerySceneTags(t *testing.T) { markers := queryMarkers(ctx, t, sqlite.SceneMarkerReaderWriter, tc.markerFilter, tc.findFilter) assert.Greater(t, len(markers), 0) for _, m := range markers { - testTags(m, tc.markerFilter) + testTags(t, m, tc.markerFilter) } }) } diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 137319c31..7b676fe76 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -668,7 +668,8 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { sceneIDs[sceneIdxWithSpacedName], clearScenePartial(), models.Scene{ - ID: sceneIDs[sceneIdxWithSpacedName], + ID: sceneIDs[sceneIdxWithSpacedName], + OCounter: getOCounter(sceneIdxWithSpacedName), Files: models.NewRelatedVideoFiles([]*file.VideoFile{ makeSceneFile(sceneIdxWithSpacedName), }), @@ -677,6 +678,10 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { PerformerIDs: models.NewRelatedIDs([]int{}), Movies: models.NewRelatedMovies([]models.MoviesScenes{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), + PlayCount: getScenePlayCount(sceneIdxWithSpacedName), + PlayDuration: getScenePlayDuration(sceneIdxWithSpacedName), + LastPlayedAt: getSceneLastPlayed(sceneIdxWithSpacedName), + ResumeTime: getSceneResumeTime(sceneIdxWithSpacedName), }, false, }, @@ -2101,6 +2106,8 @@ func sceneQueryQ(ctx context.Context, t *testing.T, sqb models.SceneReader, q st // no Q should return all results filter.Q = nil + pp := totalScenes + filter.PerPage = &pp scenes = queryScene(ctx, t, sqb, nil, &filter) assert.Len(t, scenes, totalScenes) @@ -2230,8 +2237,8 @@ func TestSceneQuery(t *testing.T) { return } - include := indexesToIDs(performerIDs, tt.includeIdxs) - exclude := indexesToIDs(performerIDs, tt.excludeIdxs) + include := indexesToIDs(sceneIDs, tt.includeIdxs) + exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(results.IDs, i) @@ -3057,7 +3064,13 @@ func queryScenes(ctx context.Context, t *testing.T, queryBuilder models.SceneRea }, } - return queryScene(ctx, t, queryBuilder, &sceneFilter, nil) + // needed so that we don't hit the default limit of 25 scenes + pp := 1000 + findFilter := &models.FindFilterType{ + PerPage: &pp, + } + + return queryScene(ctx, t, queryBuilder, &sceneFilter, findFilter) } func createScene(ctx context.Context, width int, height int) (*models.Scene, error) { @@ -3329,192 +3342,473 @@ func TestSceneQueryIsMissingPhash(t *testing.T) { } func TestSceneQueryPerformers(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Scene - performerCriterion := models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdxWithScene]), - strconv.Itoa(performerIDs[performerIdx1WithScene]), + tests := []struct { + name string + filter models.MultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdxWithScene]), + strconv.Itoa(performerIDs[performerIdx1WithScene]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - sceneFilter := models.SceneFilterType{ - Performers: &performerCriterion, - } - - scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) - - assert.Len(t, scenes, 2) - - // ensure ids are correct - for _, scene := range scenes { - assert.True(t, scene.ID == sceneIDs[sceneIdxWithPerformer] || scene.ID == sceneIDs[sceneIdxWithTwoPerformers]) - } - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithScene]), - strconv.Itoa(performerIDs[performerIdx2WithScene]), + []int{ + sceneIdxWithPerformer, + sceneIdxWithTwoPerformers, }, - Modifier: models.CriterionModifierIncludesAll, - } - - scenes = queryScene(ctx, t, sqb, &sceneFilter, nil) - - assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdxWithTwoPerformers], scenes[0].ID) - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithScene]), + []int{ + sceneIdxWithGallery, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes all", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdx1WithScene]), + strconv.Itoa(performerIDs[performerIdx2WithScene]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + sceneIdxWithTwoPerformers, + }, + []int{ + sceneIdxWithPerformer, + }, + false, + }, + { + "excludes", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[performerIdx1WithScene])}, + }, + nil, + []int{sceneIdxWithTwoPerformers}, + false, + }, + { + "is null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{sceneIdxWithTag}, + []int{ + sceneIdxWithPerformer, + sceneIdxWithTwoPerformers, + sceneIdxWithPerformerTwoTags, + }, + false, + }, + { + "not null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + sceneIdxWithPerformer, + sceneIdxWithTwoPerformers, + sceneIdxWithPerformerTwoTags, + }, + []int{sceneIdxWithTag}, + false, + }, + { + "equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithScene]), + strconv.Itoa(tagIDs[performerIdx2WithScene]), + }, + }, + []int{sceneIdxWithTwoPerformers}, + []int{ + sceneIdxWithThreePerformers, + }, + false, + }, + { + "not equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithScene]), + strconv.Itoa(tagIDs[performerIdx2WithScene]), + }, + }, + nil, + nil, + true, + }, + } - q := getSceneStringValue(sceneIdxWithTwoPerformers, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 0) + results, err := db.Scene.Query(ctx, models.SceneQueryOptions{ + SceneFilter: &models.SceneFilterType{ + Performers: &tt.filter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - return nil - }) + include := indexesToIDs(sceneIDs, tt.includeIdxs) + exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestSceneQueryTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Scene - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithScene]), - strconv.Itoa(tagIDs[tagIdx1WithScene]), + tests := []struct { + name string + filter models.HierarchicalMultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithScene]), + strconv.Itoa(tagIDs[tagIdx1WithScene]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - sceneFilter := models.SceneFilterType{ - Tags: &tagCriterion, - } - - scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) - assert.Len(t, scenes, 2) - - // ensure ids are correct - for _, scene := range scenes { - assert.True(t, scene.ID == sceneIDs[sceneIdxWithTag] || scene.ID == sceneIDs[sceneIdxWithTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithScene]), - strconv.Itoa(tagIDs[tagIdx2WithScene]), + []int{ + sceneIdxWithTag, + sceneIdxWithTwoTags, }, - Modifier: models.CriterionModifierIncludesAll, - } - - scenes = queryScene(ctx, t, sqb, &sceneFilter, nil) - - assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdxWithTwoTags], scenes[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithScene]), + []int{ + sceneIdxWithGallery, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes all", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithScene]), + strconv.Itoa(tagIDs[tagIdx2WithScene]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + sceneIdxWithTwoTags, + }, + []int{ + sceneIdxWithTag, + }, + false, + }, + { + "excludes", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx1WithScene])}, + }, + nil, + []int{sceneIdxWithTwoTags}, + false, + }, + { + "is null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{sceneIdx1WithPerformer}, + []int{ + sceneIdxWithTag, + sceneIdxWithTwoTags, + sceneIdxWithMarkerAndTag, + }, + false, + }, + { + "not null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + sceneIdxWithTag, + sceneIdxWithTwoTags, + sceneIdxWithMarkerAndTag, + }, + []int{sceneIdx1WithPerformer}, + false, + }, + { + "equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithScene]), + strconv.Itoa(tagIDs[tagIdx2WithScene]), + }, + }, + []int{sceneIdxWithTwoTags}, + []int{ + sceneIdxWithThreeTags, + }, + false, + }, + { + "not equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithScene]), + strconv.Itoa(tagIDs[tagIdx2WithScene]), + }, + }, + nil, + nil, + true, + }, + } - q := getSceneStringValue(sceneIdxWithTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 0) + results, err := db.Scene.Query(ctx, models.SceneQueryOptions{ + SceneFilter: &models.SceneFilterType{ + Tags: &tt.filter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - return nil - }) + include := indexesToIDs(sceneIDs, tt.includeIdxs) + exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestSceneQueryPerformerTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Scene - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithPerformer]), - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + allDepth := -1 + + tests := []struct { + name string + findFilter *models.FindFilterType + filter *models.SceneFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithPerformer]), + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierIncludes, + }, }, - Modifier: models.CriterionModifierIncludes, - } - - sceneFilter := models.SceneFilterType{ - PerformerTags: &tagCriterion, - } - - scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) - assert.Len(t, scenes, 2) - - // ensure ids are correct - for _, scene := range scenes { - assert.True(t, scene.ID == sceneIDs[sceneIdxWithPerformerTag] || scene.ID == sceneIDs[sceneIdxWithPerformerTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), - strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + []int{ + sceneIdxWithPerformerTag, + sceneIdxWithPerformerTwoTags, + sceneIdxWithTwoPerformerTag, }, - Modifier: models.CriterionModifierIncludesAll, - } - - scenes = queryScene(ctx, t, sqb, &sceneFilter, nil) - - assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdxWithPerformerTwoTags], scenes[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + []int{ + sceneIdxWithPerformer, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes sub-tags", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierIncludes, + }, + }, + []int{ + sceneIdxWithPerformerParentTag, + }, + []int{ + sceneIdxWithPerformer, + sceneIdxWithPerformerTag, + sceneIdxWithPerformerTwoTags, + sceneIdxWithTwoPerformerTag, + }, + false, + }, + { + "includes all", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + }, + []int{ + sceneIdxWithPerformerTwoTags, + }, + []int{ + sceneIdxWithPerformer, + sceneIdxWithPerformerTag, + sceneIdxWithTwoPerformerTag, + }, + false, + }, + { + "excludes performer tag tagIdx2WithPerformer", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx2WithPerformer])}, + }, + }, + nil, + []int{sceneIdxWithTwoPerformerTag}, + false, + }, + { + "excludes sub-tags", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierExcludes, + }, + }, + []int{ + sceneIdxWithPerformer, + sceneIdxWithPerformerTag, + sceneIdxWithPerformerTwoTags, + sceneIdxWithTwoPerformerTag, + }, + []int{ + sceneIdxWithPerformerParentTag, + }, + false, + }, + { + "is null", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + }, + []int{sceneIdx1WithPerformer}, + []int{sceneIdxWithPerformerTag}, + false, + }, + { + "not null", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + }, + []int{sceneIdxWithPerformerTag}, + []int{sceneIdx1WithPerformer}, + false, + }, + { + "equals", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + { + "not equals", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + } - q := getSceneStringValue(sceneIdxWithPerformerTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 0) + results, err := db.Scene.Query(ctx, models.SceneQueryOptions{ + SceneFilter: tt.filter, + QueryOptions: models.QueryOptions{ + FindFilter: tt.findFilter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - tagCriterion = models.HierarchicalMultiCriterionInput{ - Modifier: models.CriterionModifierIsNull, - } - q = getSceneStringValue(sceneIdx1WithPerformer, titleField) + include := indexesToIDs(sceneIDs, tt.includeIdxs) + exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdx1WithPerformer], scenes[0].ID) - - q = getSceneStringValue(sceneIdxWithPerformerTag, titleField) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 0) - - tagCriterion.Modifier = models.CriterionModifierNotNull - - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdxWithPerformerTag], scenes[0].ID) - - q = getSceneStringValue(sceneIdx1WithPerformer, titleField) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 0) - - return nil - }) + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestSceneQueryStudio(t *testing.T) { @@ -3561,6 +3855,30 @@ func TestSceneQueryStudio(t *testing.T) { []int{sceneIDs[sceneIdxWithGallery]}, false, }, + { + "equals", + "", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithScene]), + }, + Modifier: models.CriterionModifierEquals, + }, + []int{sceneIDs[sceneIdxWithStudio]}, + false, + }, + { + "not equals", + getSceneStringValue(sceneIdxWithStudio, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithScene]), + }, + Modifier: models.CriterionModifierNotEquals, + }, + []int{}, + false, + }, } qb := db.Scene diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 94c92035b..12a56947b 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -60,19 +60,24 @@ const ( sceneIdx1WithPerformer sceneIdx2WithPerformer sceneIdxWithTwoPerformers + sceneIdxWithThreePerformers sceneIdxWithTag sceneIdxWithTwoTags + sceneIdxWithThreeTags sceneIdxWithMarkerAndTag + sceneIdxWithMarkerTwoTags sceneIdxWithStudio sceneIdx1WithStudio sceneIdx2WithStudio sceneIdxWithMarkers sceneIdxWithPerformerTag + sceneIdxWithTwoPerformerTag sceneIdxWithPerformerTwoTags sceneIdxWithSpacedName sceneIdxWithStudioPerformer sceneIdxWithGrandChildStudio sceneIdxMissingPhash + sceneIdxWithPerformerParentTag // new indexes above lastSceneIdx @@ -90,16 +95,20 @@ const ( imageIdx1WithPerformer imageIdx2WithPerformer imageIdxWithTwoPerformers + imageIdxWithThreePerformers imageIdxWithTag imageIdxWithTwoTags + imageIdxWithThreeTags imageIdxWithStudio imageIdx1WithStudio imageIdx2WithStudio imageIdxWithStudioPerformer imageIdxInZip imageIdxWithPerformerTag + imageIdxWithTwoPerformerTag imageIdxWithPerformerTwoTags imageIdxWithGrandChildStudio + imageIdxWithPerformerParentTag // new indexes above totalImages ) @@ -108,20 +117,25 @@ const ( performerIdxWithScene = iota performerIdx1WithScene performerIdx2WithScene + performerIdx3WithScene performerIdxWithTwoScenes performerIdxWithImage performerIdxWithTwoImages performerIdx1WithImage performerIdx2WithImage + performerIdx3WithImage performerIdxWithTag + performerIdx2WithTag performerIdxWithTwoTags performerIdxWithGallery performerIdxWithTwoGalleries performerIdx1WithGallery performerIdx2WithGallery + performerIdx3WithGallery performerIdxWithSceneStudio performerIdxWithImageStudio performerIdxWithGalleryStudio + performerIdxWithParentTag // new indexes above // performers with dup names start from the end performerIdx1WithDupName @@ -155,16 +169,20 @@ const ( galleryIdx1WithPerformer galleryIdx2WithPerformer galleryIdxWithTwoPerformers + galleryIdxWithThreePerformers galleryIdxWithTag galleryIdxWithTwoTags + galleryIdxWithThreeTags galleryIdxWithStudio galleryIdx1WithStudio galleryIdx2WithStudio galleryIdxWithPerformerTag + galleryIdxWithTwoPerformerTag galleryIdxWithPerformerTwoTags galleryIdxWithStudioPerformer galleryIdxWithGrandChildStudio galleryIdxWithoutFile + galleryIdxWithPerformerParentTag // new indexes above lastGalleryIdx @@ -182,12 +200,14 @@ const ( tagIdxWithImage tagIdx1WithImage tagIdx2WithImage + tagIdx3WithImage tagIdxWithPerformer tagIdx1WithPerformer tagIdx2WithPerformer tagIdxWithGallery tagIdx1WithGallery tagIdx2WithGallery + tagIdx3WithGallery tagIdxWithChildTag tagIdxWithParentTag tagIdxWithGrandChild @@ -332,19 +352,24 @@ var ( var ( sceneTags = linkMap{ - sceneIdxWithTag: {tagIdxWithScene}, - sceneIdxWithTwoTags: {tagIdx1WithScene, tagIdx2WithScene}, - sceneIdxWithMarkerAndTag: {tagIdx3WithScene}, + sceneIdxWithTag: {tagIdxWithScene}, + sceneIdxWithTwoTags: {tagIdx1WithScene, tagIdx2WithScene}, + sceneIdxWithThreeTags: {tagIdx1WithScene, tagIdx2WithScene, tagIdx3WithScene}, + sceneIdxWithMarkerAndTag: {tagIdx3WithScene}, + sceneIdxWithMarkerTwoTags: {tagIdx2WithScene, tagIdx3WithScene}, } scenePerformers = linkMap{ - sceneIdxWithPerformer: {performerIdxWithScene}, - sceneIdxWithTwoPerformers: {performerIdx1WithScene, performerIdx2WithScene}, - sceneIdxWithPerformerTag: {performerIdxWithTag}, - sceneIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, - sceneIdx1WithPerformer: {performerIdxWithTwoScenes}, - sceneIdx2WithPerformer: {performerIdxWithTwoScenes}, - sceneIdxWithStudioPerformer: {performerIdxWithSceneStudio}, + sceneIdxWithPerformer: {performerIdxWithScene}, + sceneIdxWithTwoPerformers: {performerIdx1WithScene, performerIdx2WithScene}, + sceneIdxWithThreePerformers: {performerIdx1WithScene, performerIdx2WithScene, performerIdx3WithScene}, + sceneIdxWithPerformerTag: {performerIdxWithTag}, + sceneIdxWithTwoPerformerTag: {performerIdxWithTag, performerIdx2WithTag}, + sceneIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, + sceneIdx1WithPerformer: {performerIdxWithTwoScenes}, + sceneIdx2WithPerformer: {performerIdxWithTwoScenes}, + sceneIdxWithStudioPerformer: {performerIdxWithSceneStudio}, + sceneIdxWithPerformerParentTag: {performerIdxWithParentTag}, } sceneGalleries = linkMap{ @@ -376,6 +401,7 @@ var ( {sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, nil}, {sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, []int{tagIdxWithMarkers}}, {sceneIdxWithMarkerAndTag, tagIdxWithPrimaryMarkers, nil}, + {sceneIdxWithMarkerTwoTags, tagIdxWithPrimaryMarkers, nil}, } ) @@ -407,29 +433,36 @@ var ( imageIdxWithGrandChildStudio: studioIdxWithGrandParent, } imageTags = linkMap{ - imageIdxWithTag: {tagIdxWithImage}, - imageIdxWithTwoTags: {tagIdx1WithImage, tagIdx2WithImage}, + imageIdxWithTag: {tagIdxWithImage}, + imageIdxWithTwoTags: {tagIdx1WithImage, tagIdx2WithImage}, + imageIdxWithThreeTags: {tagIdx1WithImage, tagIdx2WithImage, tagIdx3WithImage}, } imagePerformers = linkMap{ - imageIdxWithPerformer: {performerIdxWithImage}, - imageIdxWithTwoPerformers: {performerIdx1WithImage, performerIdx2WithImage}, - imageIdxWithPerformerTag: {performerIdxWithTag}, - imageIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, - imageIdx1WithPerformer: {performerIdxWithTwoImages}, - imageIdx2WithPerformer: {performerIdxWithTwoImages}, - imageIdxWithStudioPerformer: {performerIdxWithImageStudio}, + imageIdxWithPerformer: {performerIdxWithImage}, + imageIdxWithTwoPerformers: {performerIdx1WithImage, performerIdx2WithImage}, + imageIdxWithThreePerformers: {performerIdx1WithImage, performerIdx2WithImage, performerIdx3WithImage}, + imageIdxWithPerformerTag: {performerIdxWithTag}, + imageIdxWithTwoPerformerTag: {performerIdxWithTag, performerIdx2WithTag}, + imageIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, + imageIdx1WithPerformer: {performerIdxWithTwoImages}, + imageIdx2WithPerformer: {performerIdxWithTwoImages}, + imageIdxWithStudioPerformer: {performerIdxWithImageStudio}, + imageIdxWithPerformerParentTag: {performerIdxWithParentTag}, } ) var ( galleryPerformers = linkMap{ - galleryIdxWithPerformer: {performerIdxWithGallery}, - galleryIdxWithTwoPerformers: {performerIdx1WithGallery, performerIdx2WithGallery}, - galleryIdxWithPerformerTag: {performerIdxWithTag}, - galleryIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, - galleryIdx1WithPerformer: {performerIdxWithTwoGalleries}, - galleryIdx2WithPerformer: {performerIdxWithTwoGalleries}, - galleryIdxWithStudioPerformer: {performerIdxWithGalleryStudio}, + galleryIdxWithPerformer: {performerIdxWithGallery}, + galleryIdxWithTwoPerformers: {performerIdx1WithGallery, performerIdx2WithGallery}, + galleryIdxWithThreePerformers: {performerIdx1WithGallery, performerIdx2WithGallery, performerIdx3WithGallery}, + galleryIdxWithPerformerTag: {performerIdxWithTag}, + galleryIdxWithTwoPerformerTag: {performerIdxWithTag, performerIdx2WithTag}, + galleryIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, + galleryIdx1WithPerformer: {performerIdxWithTwoGalleries}, + galleryIdx2WithPerformer: {performerIdxWithTwoGalleries}, + galleryIdxWithStudioPerformer: {performerIdxWithGalleryStudio}, + galleryIdxWithPerformerParentTag: {performerIdxWithParentTag}, } galleryStudios = map[int]int{ @@ -441,8 +474,9 @@ var ( } galleryTags = linkMap{ - galleryIdxWithTag: {tagIdxWithGallery}, - galleryIdxWithTwoTags: {tagIdx1WithGallery, tagIdx2WithGallery}, + galleryIdxWithTag: {tagIdxWithGallery}, + galleryIdxWithTwoTags: {tagIdx1WithGallery, tagIdx2WithGallery}, + galleryIdxWithThreeTags: {tagIdx1WithGallery, tagIdx2WithGallery, tagIdx3WithGallery}, } ) @@ -462,8 +496,10 @@ var ( var ( performerTags = linkMap{ - performerIdxWithTag: {tagIdxWithPerformer}, - performerIdxWithTwoTags: {tagIdx1WithPerformer, tagIdx2WithPerformer}, + performerIdxWithTag: {tagIdxWithPerformer}, + performerIdx2WithTag: {tagIdx2WithPerformer}, + performerIdxWithTwoTags: {tagIdx1WithPerformer, tagIdx2WithPerformer}, + performerIdxWithParentTag: {tagIdxWithParentAndChild}, } ) @@ -484,6 +520,16 @@ func indexesToIDs(ids []int, indexes []int) []int { return ret } +func indexFromID(ids []int, id int) int { + for i, v := range ids { + if v == id { + return i + } + } + + return -1 +} + var db *sqlite.Database func TestMain(m *testing.M) { @@ -1431,11 +1477,8 @@ func getTagStringValue(index int, field string) string { } func getTagSceneCount(id int) int { - if id == tagIDs[tagIdx1WithScene] || id == tagIDs[tagIdx2WithScene] || id == tagIDs[tagIdxWithScene] || id == tagIDs[tagIdx3WithScene] { - return 1 - } - - return 0 + idx := indexFromID(tagIDs, id) + return len(sceneTags.reverseLookup(idx)) } func getTagMarkerCount(id int) int { @@ -1451,27 +1494,18 @@ func getTagMarkerCount(id int) int { } func getTagImageCount(id int) int { - if id == tagIDs[tagIdx1WithImage] || id == tagIDs[tagIdx2WithImage] || id == tagIDs[tagIdxWithImage] { - return 1 - } - - return 0 + idx := indexFromID(tagIDs, id) + return len(imageTags.reverseLookup(idx)) } func getTagGalleryCount(id int) int { - if id == tagIDs[tagIdx1WithGallery] || id == tagIDs[tagIdx2WithGallery] || id == tagIDs[tagIdxWithGallery] { - return 1 - } - - return 0 + idx := indexFromID(tagIDs, id) + return len(galleryTags.reverseLookup(idx)) } func getTagPerformerCount(id int) int { - if id == tagIDs[tagIdx1WithPerformer] || id == tagIDs[tagIdx2WithPerformer] || id == tagIDs[tagIdxWithPerformer] { - return 1 - } - - return 0 + idx := indexFromID(tagIDs, id) + return len(performerTags.reverseLookup(idx)) } func getTagParentCount(id int) int { diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 22f7bde1c..0c9f7422e 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -474,9 +474,19 @@ func tagMarkerCountCriterionHandler(qb *tagQueryBuilder, markerCount *models.Int } } -func tagParentsCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { +func tagParentsCriterionHandler(qb *tagQueryBuilder, criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - if tags != nil { + 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 { @@ -489,43 +499,88 @@ func tagParentsCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalMu return } - if len(tags.Value) == 0 { + if len(tags.Value) == 0 && len(tags.Excludes) == 0 { return } - var args []interface{} - for _, val := range tags.Value { - args = append(args, val) + 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") } - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth + 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") } - - 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") } } } -func tagChildrenCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { +func tagChildrenCriterionHandler(qb *tagQueryBuilder, criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - if tags != nil { + 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 { @@ -538,36 +593,71 @@ func tagChildrenCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalM return } - if len(tags.Value) == 0 { + if len(tags.Value) == 0 && len(tags.Excludes) == 0 { return } - var args []interface{} - for _, val := range tags.Value { - args = append(args, val) + 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") } - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth + 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") } - - 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") } } } diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index d3ff5459f..5c601ca80 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -187,7 +187,7 @@ func TestTagQuerySort(t *testing.T) { tags := queryTags(ctx, t, sqb, nil, findFilter) assert := assert.New(t) - assert.Equal(tagIDs[tagIdxWithScene], tags[0].ID) + assert.Equal(tagIDs[tagIdx2WithScene], tags[0].ID) sortBy = "scene_markers_count" tags = queryTags(ctx, t, sqb, nil, findFilter) @@ -195,15 +195,15 @@ func TestTagQuerySort(t *testing.T) { sortBy = "images_count" tags = queryTags(ctx, t, sqb, nil, findFilter) - assert.Equal(tagIDs[tagIdxWithImage], tags[0].ID) + assert.Equal(tagIDs[tagIdx1WithImage], tags[0].ID) sortBy = "galleries_count" tags = queryTags(ctx, t, sqb, nil, findFilter) - assert.Equal(tagIDs[tagIdxWithGallery], tags[0].ID) + assert.Equal(tagIDs[tagIdx1WithGallery], tags[0].ID) sortBy = "performers_count" tags = queryTags(ctx, t, sqb, nil, findFilter) - assert.Equal(tagIDs[tagIdxWithPerformer], tags[0].ID) + assert.Equal(tagIDs[tagIdx2WithPerformer], tags[0].ID) return nil }) diff --git a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx index d14997ef6..48caccb16 100644 --- a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx @@ -309,14 +309,18 @@ export const HierarchicalObjectsFilter = < return (