diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index f0496fda6..9d5edeb8f 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -425,17 +425,37 @@ type joinedMultiCriterionHandlerBuilder struct { func (m *joinedMultiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { - if criterion != nil && len(criterion.Value) > 0 { - var args []interface{} - for _, tagID := range criterion.Value { - args = append(args, tagID) - } - + if criterion != nil { joinAlias := m.joinAs if joinAlias == "" { joinAlias = m.joinTable } + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + m.addJoinTable(f) + + f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ + "table": joinAlias, + "column": m.foreignFK, + "not": notClause, + })) + return + } + + if len(criterion.Value) == 0 { + return + } + + var args []interface{} + for _, tagID := range criterion.Value { + args = append(args, tagID) + } + whereClause := "" havingClause := "" @@ -475,7 +495,27 @@ type multiCriterionHandlerBuilder struct { func (m *multiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { - if criterion != nil && len(criterion.Value) > 0 { + if criterion != nil { + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + table := m.primaryTable + if m.joinTable != "" { + table = m.joinTable + f.addJoin(table, "", fmt.Sprintf("%s.%s = %s.id", table, m.primaryFK, m.primaryTable)) + } + + f.addWhere(fmt.Sprintf("%s.%s IS %s NULL", table, m.foreignFK, notClause)) + return + } + + if len(criterion.Value) == 0 { + return + } + var args []interface{} for _, tagID := range criterion.Value { args = append(args, tagID) @@ -637,7 +677,25 @@ func addHierarchicalConditionClauses(f *filterBuilder, criterion *models.Hierarc func (m *hierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { - if criterion != nil && len(criterion.Value) > 0 { + if criterion != nil { + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ + "table": m.primaryTable, + "column": m.foreignFK, + "not": notClause, + })) + return + } + + if len(criterion.Value) == 0 { + return + } + valuesClause := getHierarchicalValues(m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) f.addJoin("(SELECT column1 AS root_id, column2 AS item_id FROM ("+valuesClause+"))", m.derivedTable, fmt.Sprintf("%s.item_id = %s.%s", m.derivedTable, m.primaryTable, m.foreignFK)) @@ -664,10 +722,31 @@ type joinedHierarchicalMultiCriterionHandlerBuilder struct { func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { - if criterion != nil && len(criterion.Value) > 0 { + if criterion != nil { + joinAlias := m.joinAs + + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addJoin(m.joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.id", joinAlias, m.primaryFK, m.primaryTable)) + + f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ + "table": joinAlias, + "column": m.foreignFK, + "not": notClause, + })) + return + } + + if len(criterion.Value) == 0 { + return + } + valuesClause := getHierarchicalValues(m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) - joinAlias := m.joinAs joinTable := utils.StrFormat(`( SELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j INNER JOIN ({valuesClause}) AS d ON j.{foreignFK} = d.column2 diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 342388c70..cc79e1a89 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -390,7 +390,24 @@ func galleryStudioCriterionHandler(qb *galleryQueryBuilder, studios *models.Hier func galleryPerformerTagsCriterionHandler(qb *galleryQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { - if tags != nil && len(tags.Value) > 0 { + if tags != nil { + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { + var notClause string + if tags.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addJoin("performers_galleries", "", "galleries.id = performers_galleries.gallery_id") + f.addJoin("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(qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) f.addWith(`performer_tags AS ( diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index ac7007d9d..a121e4b5c 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -825,6 +825,29 @@ func TestGalleryQueryPerformerTags(t *testing.T) { galleries = queryGallery(t, sqb, &galleryFilter, &findFilter) assert.Len(t, galleries, 0) + tagCriterion = models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + } + q = getGalleryStringValue(galleryIdx1WithImage, titleField) + + galleries = queryGallery(t, sqb, &galleryFilter, &findFilter) + assert.Len(t, galleries, 1) + assert.Equal(t, galleryIDs[galleryIdx1WithImage], galleries[0].ID) + + q = getGalleryStringValue(galleryIdxWithPerformerTag, titleField) + galleries = queryGallery(t, sqb, &galleryFilter, &findFilter) + assert.Len(t, galleries, 0) + + tagCriterion.Modifier = models.CriterionModifierNotNull + + galleries = queryGallery(t, sqb, &galleryFilter, &findFilter) + assert.Len(t, galleries, 1) + assert.Equal(t, galleryIDs[galleryIdxWithPerformerTag], galleries[0].ID) + + q = getGalleryStringValue(galleryIdx1WithImage, titleField) + galleries = queryGallery(t, sqb, &galleryFilter, &findFilter) + assert.Len(t, galleries, 0) + return nil }) } diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index f82c9e2ba..12121ef90 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -464,7 +464,24 @@ func imageStudioCriterionHandler(qb *imageQueryBuilder, studios *models.Hierarch func imagePerformerTagsCriterionHandler(qb *imageQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { - if tags != nil && len(tags.Value) > 0 { + if tags != nil { + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { + var notClause string + if tags.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addJoin("performers_images", "", "images.id = performers_images.image_id") + f.addJoin("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(qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) f.addWith(`performer_tags AS ( diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 783ad5f56..141fbb3d6 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -680,11 +680,7 @@ func TestImageQueryPerformers(t *testing.T) { Performers: &performerCriterion, } - images, _, err := queryImagesWithCount(sqb, &imageFilter, nil) - if err != nil { - t.Errorf("Error querying image: %s", err.Error()) - } - + images := queryImages(t, sqb, &imageFilter, nil) assert.Len(t, images, 2) // ensure ids are correct @@ -700,11 +696,7 @@ func TestImageQueryPerformers(t *testing.T) { Modifier: models.CriterionModifierIncludesAll, } - images, _, err = queryImagesWithCount(sqb, &imageFilter, nil) - if err != nil { - t.Errorf("Error querying image: %s", err.Error()) - } - + images = queryImages(t, sqb, &imageFilter, nil) assert.Len(t, images, 1) assert.Equal(t, imageIDs[imageIdxWithTwoPerformers], images[0].ID) @@ -720,10 +712,30 @@ func TestImageQueryPerformers(t *testing.T) { Q: &q, } - images, _, err = queryImagesWithCount(sqb, &imageFilter, &findFilter) - if err != nil { - t.Errorf("Error querying image: %s", err.Error()) + images = queryImages(t, sqb, &imageFilter, &findFilter) + assert.Len(t, images, 0) + + performerCriterion = models.MultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, } + q = getImageStringValue(imageIdxWithGallery, titleField) + + images = queryImages(t, sqb, &imageFilter, &findFilter) + assert.Len(t, images, 1) + assert.Equal(t, imageIDs[imageIdxWithGallery], images[0].ID) + + q = getImageStringValue(imageIdxWithPerformerTag, titleField) + images = queryImages(t, sqb, &imageFilter, &findFilter) + assert.Len(t, images, 0) + + performerCriterion.Modifier = models.CriterionModifierNotNull + + images = queryImages(t, sqb, &imageFilter, &findFilter) + assert.Len(t, images, 1) + assert.Equal(t, imageIDs[imageIdxWithPerformerTag], images[0].ID) + + q = getImageStringValue(imageIdxWithGallery, titleField) + images = queryImages(t, sqb, &imageFilter, &findFilter) assert.Len(t, images, 0) return nil @@ -745,11 +757,7 @@ func TestImageQueryTags(t *testing.T) { Tags: &tagCriterion, } - images, _, err := queryImagesWithCount(sqb, &imageFilter, nil) - if err != nil { - t.Errorf("Error querying image: %s", err.Error()) - } - + images := queryImages(t, sqb, &imageFilter, nil) assert.Len(t, images, 2) // ensure ids are correct @@ -765,11 +773,7 @@ func TestImageQueryTags(t *testing.T) { Modifier: models.CriterionModifierIncludesAll, } - images, _, err = queryImagesWithCount(sqb, &imageFilter, nil) - if err != nil { - t.Errorf("Error querying image: %s", err.Error()) - } - + images = queryImages(t, sqb, &imageFilter, nil) assert.Len(t, images, 1) assert.Equal(t, imageIDs[imageIdxWithTwoTags], images[0].ID) @@ -785,10 +789,30 @@ func TestImageQueryTags(t *testing.T) { Q: &q, } - images, _, err = queryImagesWithCount(sqb, &imageFilter, &findFilter) - if err != nil { - t.Errorf("Error querying image: %s", err.Error()) + images = queryImages(t, sqb, &imageFilter, &findFilter) + assert.Len(t, images, 0) + + tagCriterion = models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, } + q = getImageStringValue(imageIdxWithGallery, titleField) + + images = queryImages(t, sqb, &imageFilter, &findFilter) + assert.Len(t, images, 1) + assert.Equal(t, imageIDs[imageIdxWithGallery], images[0].ID) + + q = getImageStringValue(imageIdxWithTag, titleField) + images = queryImages(t, sqb, &imageFilter, &findFilter) + assert.Len(t, images, 0) + + tagCriterion.Modifier = models.CriterionModifierNotNull + + images = queryImages(t, sqb, &imageFilter, &findFilter) + assert.Len(t, images, 1) + assert.Equal(t, imageIDs[imageIdxWithTag], images[0].ID) + + q = getImageStringValue(imageIdxWithGallery, titleField) + images = queryImages(t, sqb, &imageFilter, &findFilter) assert.Len(t, images, 0) return nil @@ -962,6 +986,29 @@ func TestImageQueryPerformerTags(t *testing.T) { images = queryImages(t, sqb, &imageFilter, &findFilter) assert.Len(t, images, 0) + tagCriterion = models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + } + q = getImageStringValue(imageIdxWithGallery, titleField) + + images = queryImages(t, sqb, &imageFilter, &findFilter) + assert.Len(t, images, 1) + assert.Equal(t, imageIDs[imageIdxWithGallery], images[0].ID) + + q = getImageStringValue(imageIdxWithPerformerTag, titleField) + images = queryImages(t, sqb, &imageFilter, &findFilter) + assert.Len(t, images, 0) + + tagCriterion.Modifier = models.CriterionModifierNotNull + + images = queryImages(t, sqb, &imageFilter, &findFilter) + assert.Len(t, images, 1) + assert.Equal(t, imageIDs[imageIdxWithPerformerTag], images[0].ID) + + q = getImageStringValue(imageIdxWithGallery, titleField) + images = queryImages(t, sqb, &imageFilter, &findFilter) + assert.Len(t, images, 0) + return nil }) } diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index 999372955..c954db942 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -209,7 +209,24 @@ func movieStudioCriterionHandler(qb *movieQueryBuilder, studios *models.Hierarch func moviePerformersCriterionHandler(qb *movieQueryBuilder, performers *models.MultiCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { - if performers != nil && len(performers.Value) > 0 { + if performers != nil { + if performers.Modifier == models.CriterionModifierIsNull || performers.Modifier == models.CriterionModifierNotNull { + var notClause string + if performers.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addJoin("movies_scenes", "", "movies.id = movies_scenes.movie_id") + f.addJoin("performers_scenes", "", "movies_scenes.scene_id = performers_scenes.scene_id") + + f.addWhere(fmt.Sprintf("performers_scenes.performer_id IS %s NULL", notClause)) + return + } + + if len(performers.Value) == 0 { + return + } + var args []interface{} for _, arg := range performers.Value { args = append(args, arg) diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 71a63c917..33ad50e43 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -439,19 +439,6 @@ func performerGalleryCountCriterionHandler(qb *performerQueryBuilder, count *mod func performerStudiosCriterionHandler(qb *performerQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { if studios != nil { - var clauseCondition string - - switch studios.Modifier { - case models.CriterionModifierIncludes: - // return performers who appear in scenes/images/galleries with any of the given studios - clauseCondition = "NOT" - case models.CriterionModifierExcludes: - // exclude performers who appear in scenes/images/galleries with any of the given studios - clauseCondition = "" - default: - return - } - formatMaps := []utils.StrFormatMap{ { "primaryTable": sceneTable, @@ -470,6 +457,41 @@ func performerStudiosCriterionHandler(qb *performerQueryBuilder, studios *models }, } + if studios.Modifier == models.CriterionModifierIsNull || studios.Modifier == models.CriterionModifierNotNull { + var notClause string + if studios.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + var conditions []string + for _, c := range formatMaps { + f.addJoin(c["joinTable"].(string), "", fmt.Sprintf("%s.performer_id = performers.id", c["joinTable"])) + f.addJoin(c["primaryTable"].(string), "", fmt.Sprintf("%s.%s = %s.id", c["joinTable"], c["primaryFK"], c["primaryTable"])) + + conditions = append(conditions, fmt.Sprintf("%s.studio_id IS NULL", c["primaryTable"])) + } + + f.addWhere(fmt.Sprintf("%s (%s)", notClause, strings.Join(conditions, " AND "))) + return + } + + if len(studios.Value) == 0 { + return + } + + var clauseCondition string + + switch studios.Modifier { + case models.CriterionModifierIncludes: + // return performers who appear in scenes/images/galleries with any of the given studios + clauseCondition = "NOT" + case models.CriterionModifierExcludes: + // exclude performers who appear in scenes/images/galleries with any of the given studios + clauseCondition = "" + default: + return + } + const derivedPerformerStudioTable = "performer_studio" valuesClause := getHierarchicalValues(qb.tx, studios.Value, studioTable, "", "parent_id", studios.Depth) f.addWith("studio(root_id, item_id) AS (" + valuesClause + ")") @@ -483,7 +505,7 @@ func performerStudiosCriterionHandler(qb *performerQueryBuilder, studios *models unions = append(unions, utils.StrFormat(templStr, c)) } - f.addWith(fmt.Sprintf("%s AS (%s)", "performer_studio", strings.Join(unions, " UNION "))) + f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerStudioTable, strings.Join(unions, " UNION "))) f.addJoin(derivedPerformerStudioTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerStudioTable)) f.addWhere(fmt.Sprintf("%s.performer_id IS %s NULL", derivedPerformerStudioTable, clauseCondition)) diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 551a229e8..9bd8e05f0 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -787,6 +787,34 @@ func TestPerformerQueryStudio(t *testing.T) { assert.Len(t, performers, 0) } + // test NULL/not NULL + q := getPerformerStringValue(performerIdx1WithImage, "Name") + performerFilter := &models.PerformerFilterType{ + Studios: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + } + findFilter := &models.FindFilterType{ + Q: &q, + } + + performers := queryPerformers(t, sqb, performerFilter, findFilter) + assert.Len(t, performers, 1) + assert.Equal(t, imageIDs[performerIdx1WithImage], performers[0].ID) + + q = getPerformerStringValue(performerIdxWithSceneStudio, "Name") + performers = queryPerformers(t, sqb, performerFilter, findFilter) + assert.Len(t, performers, 0) + + performerFilter.Studios.Modifier = models.CriterionModifierNotNull + performers = queryPerformers(t, sqb, performerFilter, findFilter) + assert.Len(t, performers, 1) + assert.Equal(t, imageIDs[performerIdxWithSceneStudio], performers[0].ID) + + q = getPerformerStringValue(performerIdx1WithImage, "Name") + performers = queryPerformers(t, sqb, performerFilter, findFilter) + assert.Len(t, performers, 0) + return nil }) } diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 97d0b6899..1edf73d11 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -668,7 +668,24 @@ func sceneMoviesCriterionHandler(qb *sceneQueryBuilder, movies *models.MultiCrit func scenePerformerTagsCriterionHandler(qb *sceneQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { - if tags != nil && len(tags.Value) > 0 { + if tags != nil { + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { + var notClause string + if tags.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addJoin("performers_scenes", "", "scenes.id = performers_scenes.scene_id") + f.addJoin("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(qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) f.addWith(`performer_tags AS ( diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index 500d65966..02e610631 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -191,7 +191,22 @@ func sceneMarkerTagIDCriterionHandler(qb *sceneMarkerQueryBuilder, tagID *string func sceneMarkerTagsCriterionHandler(qb *sceneMarkerQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { - if tags != nil && len(tags.Value) > 0 { + if tags != nil { + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { + var notClause string + if tags.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addJoin("scene_markers_tags", "", "scene_markers.id = scene_markers_tags.scene_marker_id") + + f.addWhere(fmt.Sprintf("%s scene_markers_tags.tag_id IS NULL", notClause)) + return + } + + if len(tags.Value) == 0 { + return + } valuesClause := getHierarchicalValues(qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) f.addWith(`marker_tags AS ( @@ -211,7 +226,23 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = m.primary_tag_id func sceneMarkerSceneTagsCriterionHandler(qb *sceneMarkerQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { - if tags != nil && len(tags.Value) > 0 { + if tags != nil { + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { + var notClause string + if tags.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addJoin("scenes_tags", "", "scene_markers.scene_id = scenes_tags.scene_id") + + f.addWhere(fmt.Sprintf("scenes_tags.tag_id IS %s NULL", notClause)) + return + } + + if len(tags.Value) == 0 { + return + } + valuesClause := getHierarchicalValues(qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) f.addWith(`scene_tags AS ( diff --git a/pkg/sqlite/scene_marker_test.go b/pkg/sqlite/scene_marker_test.go index d50c181de..2fa0d7501 100644 --- a/pkg/sqlite/scene_marker_test.go +++ b/pkg/sqlite/scene_marker_test.go @@ -14,15 +14,17 @@ func TestMarkerFindBySceneID(t *testing.T) { withTxn(func(r models.Repository) error { mqb := r.SceneMarker() - sceneID := sceneIDs[sceneIdxWithMarker] + sceneID := sceneIDs[sceneIdxWithMarkers] markers, err := mqb.FindBySceneID(sceneID) if err != nil { t.Errorf("Error finding markers: %s", err.Error()) } - assert.Len(t, markers, 1) - assert.Equal(t, markerIDs[markerIdxWithScene], markers[0].ID) + assert.Greater(t, len(markers), 0) + for _, marker := range markers { + assert.Equal(t, sceneIDs[sceneIdxWithMarkers], int(marker.SceneID.Int64)) + } markers, err = mqb.FindBySceneID(0) @@ -40,15 +42,15 @@ func TestMarkerCountByTagID(t *testing.T) { withTxn(func(r models.Repository) error { mqb := r.SceneMarker() - markerCount, err := mqb.CountByTagID(tagIDs[tagIdxWithPrimaryMarker]) + markerCount, err := mqb.CountByTagID(tagIDs[tagIdxWithPrimaryMarkers]) if err != nil { t.Errorf("error calling CountByTagID: %s", err.Error()) } - assert.Equal(t, 1, markerCount) + assert.Equal(t, 3, markerCount) - markerCount, err = mqb.CountByTagID(tagIDs[tagIdxWithMarker]) + markerCount, err = mqb.CountByTagID(tagIDs[tagIdxWithMarkers]) if err != nil { t.Errorf("error calling CountByTagID: %s", err.Error()) @@ -83,6 +85,128 @@ func TestMarkerQuerySortBySceneUpdated(t *testing.T) { }) } +func TestMarkerQueryTags(t *testing.T) { + type test struct { + name string + markerFilter *models.SceneMarkerFilterType + findFilter *models.FindFilterType + } + + withTxn(func(r models.Repository) error { + testTags := func(m *models.SceneMarker, markerFilter *models.SceneMarkerFilterType) { + tagIDs, err := r.SceneMarker().GetTagIDs(m.ID) + if err != nil { + t.Errorf("error getting marker tag ids: %v", err) + } + if markerFilter.Tags.Modifier == models.CriterionModifierIsNull && len(tagIDs) > 0 { + t.Errorf("expected marker %d to have no tags - found %d", m.ID, len(tagIDs)) + } + if markerFilter.Tags.Modifier == models.CriterionModifierNotNull && len(tagIDs) == 0 { + t.Errorf("expected marker %d to have tags - found 0", m.ID) + } + } + + cases := []test{ + { + "is null", + &models.SceneMarkerFilterType{ + Tags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + }, + nil, + }, + { + "not null", + &models.SceneMarkerFilterType{ + Tags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + }, + nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + markers := queryMarkers(t, r.SceneMarker(), tc.markerFilter, tc.findFilter) + assert.Greater(t, len(markers), 0) + for _, m := range markers { + testTags(m, tc.markerFilter) + } + }) + } + + return nil + }) +} + +func TestMarkerQuerySceneTags(t *testing.T) { + type test struct { + name string + markerFilter *models.SceneMarkerFilterType + findFilter *models.FindFilterType + } + + withTxn(func(r models.Repository) error { + testTags := func(m *models.SceneMarker, markerFilter *models.SceneMarkerFilterType) { + tagIDs, err := r.Scene().GetTagIDs(int(m.SceneID.Int64)) + if err != nil { + t.Errorf("error getting marker tag ids: %v", err) + } + 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) + } + } + + cases := []test{ + { + "is null", + &models.SceneMarkerFilterType{ + SceneTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + }, + nil, + }, + { + "not null", + &models.SceneMarkerFilterType{ + SceneTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + }, + nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + markers := queryMarkers(t, r.SceneMarker(), tc.markerFilter, tc.findFilter) + assert.Greater(t, len(markers), 0) + for _, m := range markers { + testTags(m, tc.markerFilter) + } + }) + } + + return nil + }) +} + +func queryMarkers(t *testing.T, sqb models.SceneMarkerReader, markerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) []*models.SceneMarker { + t.Helper() + result, _, err := sqb.Query(markerFilter, findFilter) + if err != nil { + t.Errorf("Error querying markers: %v", err) + } + + return result +} + // TODO Update // TODO Destroy // TODO Find diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index eb9a64bf7..0c45a2c0e 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -775,7 +775,7 @@ func TestSceneQueryHasMarkers(t *testing.T) { HasMarkers: &hasMarkers, } - q := getSceneStringValue(sceneIdxWithMarker, titleField) + q := getSceneStringValue(sceneIdxWithMarkers, titleField) findFilter := models.FindFilterType{ Q: &q, } @@ -783,7 +783,7 @@ func TestSceneQueryHasMarkers(t *testing.T) { scenes := queryScene(t, sqb, &sceneFilter, &findFilter) assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdxWithMarker], scenes[0].ID) + assert.Equal(t, sceneIDs[sceneIdxWithMarkers], scenes[0].ID) hasMarkers = "false" scenes = queryScene(t, sqb, &sceneFilter, &findFilter) @@ -796,7 +796,7 @@ func TestSceneQueryHasMarkers(t *testing.T) { // ensure non of the ids equal the one with gallery for _, scene := range scenes { - assert.NotEqual(t, sceneIDs[sceneIdxWithMarker], scene.ID) + assert.NotEqual(t, sceneIDs[sceneIdxWithMarkers], scene.ID) } return nil @@ -1151,6 +1151,29 @@ func TestSceneQueryPerformerTags(t *testing.T) { scenes = queryScene(t, sqb, &sceneFilter, &findFilter) assert.Len(t, scenes, 0) + tagCriterion = models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + } + q = getSceneStringValue(sceneIdx1WithPerformer, titleField) + + scenes = queryScene(t, sqb, &sceneFilter, &findFilter) + assert.Len(t, scenes, 1) + assert.Equal(t, sceneIDs[sceneIdx1WithPerformer], scenes[0].ID) + + q = getSceneStringValue(sceneIdxWithPerformerTag, titleField) + scenes = queryScene(t, sqb, &sceneFilter, &findFilter) + assert.Len(t, scenes, 0) + + tagCriterion.Modifier = models.CriterionModifierNotNull + + scenes = queryScene(t, sqb, &sceneFilter, &findFilter) + assert.Len(t, scenes, 1) + assert.Equal(t, sceneIDs[sceneIdxWithPerformerTag], scenes[0].ID) + + q = getSceneStringValue(sceneIdx1WithPerformer, titleField) + scenes = queryScene(t, sqb, &sceneFilter, &findFilter) + assert.Len(t, scenes, 0) + return nil }) } diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 116d8eabb..e1aaf4f9d 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -34,10 +34,11 @@ const ( sceneIdxWithTwoPerformers sceneIdxWithTag sceneIdxWithTwoTags + sceneIdxWithMarkerAndTag sceneIdxWithStudio sceneIdx1WithStudio sceneIdx2WithStudio - sceneIdxWithMarker + sceneIdxWithMarkers sceneIdxWithPerformerTag sceneIdxWithPerformerTwoTags sceneIdxWithSpacedName @@ -139,8 +140,9 @@ const ( tagIdxWithScene = iota tagIdx1WithScene tagIdx2WithScene - tagIdxWithPrimaryMarker - tagIdxWithMarker + tagIdx3WithScene + tagIdxWithPrimaryMarkers + tagIdxWithMarkers tagIdxWithCoverImage tagIdxWithImage tagIdx1WithImage @@ -191,6 +193,9 @@ const ( const ( markerIdxWithScene = iota + markerIdxWithTag + markerIdxWithSceneTag + totalMarkers ) const ( @@ -239,6 +244,7 @@ var ( {sceneIdxWithTag, tagIdxWithScene}, {sceneIdxWithTwoTags, tagIdx1WithScene}, {sceneIdxWithTwoTags, tagIdx2WithScene}, + {sceneIdxWithMarkerAndTag, tagIdx3WithScene}, } scenePerformerLinks = [][2]int{ @@ -269,6 +275,21 @@ var ( } ) +type markerSpec struct { + sceneIdx int + primaryTagIdx int + tagIdxs []int +} + +var ( + // indexed by marker + markerSpecs = []markerSpec{ + {sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, nil}, + {sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, []int{tagIdxWithMarkers}}, + {sceneIdxWithMarkerAndTag, tagIdxWithPrimaryMarkers, nil}, + } +) + var ( imageGalleryLinks = [][2]int{ {imageIdxWithGallery, galleryIdxWithImage}, @@ -516,8 +537,10 @@ func populateDB() error { return fmt.Errorf("error linking tags parent: %s", err.Error()) } - if err := createMarker(r.SceneMarker(), sceneIdxWithMarker, tagIdxWithPrimaryMarker, []int{tagIdxWithMarker}); err != nil { - return fmt.Errorf("error creating scene marker: %s", err.Error()) + for _, ms := range markerSpecs { + if err := createMarker(r.SceneMarker(), ms); err != nil { + return fmt.Errorf("error creating scene marker: %s", err.Error()) + } } return nil @@ -687,6 +710,7 @@ func createGalleries(gqb models.GalleryReaderWriter, n int) error { for i := 0; i < n; i++ { gallery := models.Gallery{ Path: models.NullString(getGalleryStringValue(i, pathField)), + Title: models.NullString(getGalleryStringValue(i, titleField)), URL: getGalleryNullStringValue(i, urlField), Checksum: getGalleryStringValue(i, checksumField), Rating: getRating(i), @@ -843,7 +867,7 @@ func getTagStringValue(index int, field string) string { } func getTagSceneCount(id int) int { - if id == tagIDs[tagIdx1WithScene] || id == tagIDs[tagIdx2WithScene] || id == tagIDs[tagIdxWithScene] { + if id == tagIDs[tagIdx1WithScene] || id == tagIDs[tagIdx2WithScene] || id == tagIDs[tagIdxWithScene] || id == tagIDs[tagIdx3WithScene] { return 1 } @@ -851,7 +875,11 @@ func getTagSceneCount(id int) int { } func getTagMarkerCount(id int) int { - if id == tagIDs[tagIdxWithMarker] || id == tagIDs[tagIdxWithPrimaryMarker] { + if id == tagIDs[tagIdxWithPrimaryMarkers] { + return 3 + } + + if id == tagIDs[tagIdxWithMarkers] { return 1 } @@ -1008,28 +1036,30 @@ func createStudios(sqb models.StudioReaderWriter, n int, o int) error { return nil } -func createMarker(mqb models.SceneMarkerReaderWriter, sceneIdx, primaryTagIdx int, tagIdxs []int) error { +func createMarker(mqb models.SceneMarkerReaderWriter, markerSpec markerSpec) error { marker := models.SceneMarker{ - SceneID: sql.NullInt64{Int64: int64(sceneIDs[sceneIdx]), Valid: true}, - PrimaryTagID: tagIDs[primaryTagIdx], + SceneID: sql.NullInt64{Int64: int64(sceneIDs[markerSpec.sceneIdx]), Valid: true}, + PrimaryTagID: tagIDs[markerSpec.primaryTagIdx], } created, err := mqb.Create(marker) if err != nil { - return fmt.Errorf("Error creating marker %v+: %s", marker, err.Error()) + return fmt.Errorf("error creating marker %v+: %w", marker, err) } markerIDs = append(markerIDs, created.ID) - newTagIDs := []int{} + if len(markerSpec.tagIdxs) > 0 { + newTagIDs := []int{} - for _, tagIdx := range tagIdxs { - newTagIDs = append(newTagIDs, tagIDs[tagIdx]) - } + for _, tagIdx := range markerSpec.tagIdxs { + newTagIDs = append(newTagIDs, tagIDs[tagIdx]) + } - if err := mqb.UpdateTags(created.ID, newTagIDs); err != nil { - return fmt.Errorf("Error creating marker/tag join: %s", err.Error()) + if err := mqb.UpdateTags(created.ID, newTagIDs); err != nil { + return fmt.Errorf("error creating marker/tag join: %w", err) + } } return nil diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 87474ebcc..ea7042251 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -443,7 +443,23 @@ func tagMarkerCountCriterionHandler(qb *tagQueryBuilder, markerCount *models.Int func tagParentsCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { - if tags != nil && len(tags.Value) > 0 { + if tags != nil { + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { + var notClause string + if tags.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addJoin("tags_relations", "parent_relations", "tags.id = parent_relations.child_id") + + f.addWhere(fmt.Sprintf("parent_relations.parent_id IS %s NULL", notClause)) + return + } + + if len(tags.Value) == 0 { + return + } + var args []interface{} for _, val := range tags.Value { args = append(args, val) @@ -476,7 +492,23 @@ func tagParentsCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalMu func tagChildrenCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { - if tags != nil && len(tags.Value) > 0 { + if tags != nil { + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { + var notClause string + if tags.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addJoin("tags_relations", "child_relations", "tags.id = child_relations.parent_id") + + f.addWhere(fmt.Sprintf("child_relations.child_id IS %s NULL", notClause)) + return + } + + if len(tags.Value) == 0 { + return + } + var args []interface{} for _, val := range tags.Value { args = append(args, val) diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index d91325280..70284019f 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -18,7 +18,7 @@ func TestMarkerFindBySceneMarkerID(t *testing.T) { withTxn(func(r models.Repository) error { tqb := r.Tag() - markerID := markerIDs[markerIdxWithScene] + markerID := markerIDs[markerIdxWithTag] tags, err := tqb.FindBySceneMarkerID(markerID) @@ -27,7 +27,7 @@ func TestMarkerFindBySceneMarkerID(t *testing.T) { } assert.Len(t, tags, 1) - assert.Equal(t, tagIDs[tagIdxWithMarker], tags[0].ID) + assert.Equal(t, tagIDs[tagIdxWithMarkers], tags[0].ID) tags, err = tqb.FindBySceneMarkerID(0) @@ -168,7 +168,7 @@ func TestTagQuerySort(t *testing.T) { sortBy = "scene_markers_count" tags = queryTags(t, sqb, nil, findFilter) - assert.Equal(tagIDs[tagIdxWithMarker], tags[0].ID) + assert.Equal(tagIDs[tagIdxWithMarkers], tags[0].ID) sortBy = "images_count" tags = queryTags(t, sqb, nil, findFilter) @@ -613,6 +613,7 @@ func verifyTagChildCount(t *testing.T, sceneCountCriterion models.IntCriterionIn func TestTagQueryParent(t *testing.T) { withTxn(func(r models.Repository) error { + const nameField = "Name" sqb := r.Tag() tagCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ @@ -634,7 +635,7 @@ func TestTagQueryParent(t *testing.T) { tagCriterion.Modifier = models.CriterionModifierExcludes - q := getTagStringValue(tagIdxWithParentTag, titleField) + q := getTagStringValue(tagIdxWithParentTag, nameField) findFilter := models.FindFilterType{ Q: &q, } @@ -660,12 +661,37 @@ func TestTagQueryParent(t *testing.T) { tags = queryTags(t, sqb, &tagFilter, nil) assert.Len(t, tags, 2) + tagCriterion = models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + } + q = getTagStringValue(tagIdxWithGallery, nameField) + + tags = queryTags(t, sqb, &tagFilter, &findFilter) + assert.Len(t, tags, 1) + assert.Equal(t, tagIDs[tagIdxWithGallery], tags[0].ID) + + q = getTagStringValue(tagIdxWithParentTag, nameField) + tags = queryTags(t, sqb, &tagFilter, &findFilter) + assert.Len(t, tags, 0) + + tagCriterion.Modifier = models.CriterionModifierNotNull + + tags = queryTags(t, sqb, &tagFilter, &findFilter) + assert.Len(t, tags, 1) + assert.Equal(t, tagIDs[tagIdxWithParentTag], tags[0].ID) + + q = getTagStringValue(tagIdxWithGallery, nameField) + tags = queryTags(t, sqb, &tagFilter, &findFilter) + assert.Len(t, tags, 0) + return nil }) } func TestTagQueryChild(t *testing.T) { withTxn(func(r models.Repository) error { + const nameField = "Name" + sqb := r.Tag() tagCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ @@ -687,7 +713,7 @@ func TestTagQueryChild(t *testing.T) { tagCriterion.Modifier = models.CriterionModifierExcludes - q := getTagStringValue(tagIdxWithChildTag, titleField) + q := getTagStringValue(tagIdxWithChildTag, nameField) findFilter := models.FindFilterType{ Q: &q, } @@ -713,6 +739,29 @@ func TestTagQueryChild(t *testing.T) { tags = queryTags(t, sqb, &tagFilter, nil) assert.Len(t, tags, 2) + tagCriterion = models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + } + q = getTagStringValue(tagIdxWithGallery, nameField) + + tags = queryTags(t, sqb, &tagFilter, &findFilter) + assert.Len(t, tags, 1) + assert.Equal(t, tagIDs[tagIdxWithGallery], tags[0].ID) + + q = getTagStringValue(tagIdxWithChildTag, nameField) + tags = queryTags(t, sqb, &tagFilter, &findFilter) + assert.Len(t, tags, 0) + + tagCriterion.Modifier = models.CriterionModifierNotNull + + tags = queryTags(t, sqb, &tagFilter, &findFilter) + assert.Len(t, tags, 1) + assert.Equal(t, tagIDs[tagIdxWithChildTag], tags[0].ID) + + q = getTagStringValue(tagIdxWithGallery, nameField) + tags = queryTags(t, sqb, &tagFilter, &findFilter) + assert.Len(t, tags, 0) + return nil }) } @@ -842,8 +891,8 @@ func TestTagMerge(t *testing.T) { srcIdxs := []int{ tagIdx1WithScene, tagIdx2WithScene, - tagIdxWithPrimaryMarker, - tagIdxWithMarker, + tagIdxWithPrimaryMarkers, + tagIdxWithMarkers, tagIdxWithCoverImage, tagIdxWithImage, tagIdx1WithImage, @@ -893,7 +942,7 @@ func TestTagMerge(t *testing.T) { assert.Contains(sceneTagIDs, destID) // ensure marker points to new tag - marker, err := r.SceneMarker().Find(markerIDs[markerIdxWithScene]) + marker, err := r.SceneMarker().Find(markerIDs[markerIdxWithTag]) if err != nil { return err } diff --git a/ui/v2.5/src/components/Changelog/versions/v0110.md b/ui/v2.5/src/components/Changelog/versions/v0110.md index 19a14652e..a46e32b00 100644 --- a/ui/v2.5/src/components/Changelog/versions/v0110.md +++ b/ui/v2.5/src/components/Changelog/versions/v0110.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Support is (not) null for multi-relational filter criteria. ([#1785](https://github.com/stashapp/stash/pull/1785)) * Optionally open browser on startup (enabled by default for new systems). ([#1832](https://github.com/stashapp/stash/pull/1832)) * Support setting defaults for Delete File and Delete Generated Files in the Interface Settings. ([#1852](https://github.com/stashapp/stash/pull/1852)) * Added Identify task to automatically identify scenes from stash-box/scraper sources. See manual entry for details. ([#1839](https://github.com/stashapp/stash/pull/1839)) diff --git a/ui/v2.5/src/hooks/ListHook.tsx b/ui/v2.5/src/hooks/ListHook.tsx index f5e6e9589..3df899d48 100644 --- a/ui/v2.5/src/hooks/ListHook.tsx +++ b/ui/v2.5/src/hooks/ListHook.tsx @@ -655,9 +655,14 @@ const useList = ( defaultFilter?.findDefaultFilter ) { newFilter.currentPage = 1; - newFilter.configureFromQueryParameters( - JSON.parse(defaultFilter.findDefaultFilter.filter) - ); + try { + newFilter.configureFromQueryParameters( + JSON.parse(defaultFilter.findDefaultFilter.filter) + ); + } catch (err) { + console.log(err); + // ignore + } // #1507 - reset random seed when loaded newFilter.randomSeed = -1; update = true; diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 69e36523b..3819bd417 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -364,6 +364,8 @@ export class ILabeledIdCriterionOption extends CriterionOption { const modifierOptions = [ CriterionModifier.Includes, CriterionModifier.Excludes, + CriterionModifier.IsNull, + CriterionModifier.NotNull, ]; let defaultModifier = CriterionModifier.Includes;