From 25274e2596ecc4f67d62cde32ba488e201442a4c Mon Sep 17 00:00:00 2001 From: gitgiggety <79809426+gitgiggety@users.noreply.github.com> Date: Sat, 6 Nov 2021 23:34:33 +0100 Subject: [PATCH] Support Is (not) null for all multi criterions (#1785) * Support Is (not) null for all multi criterions Add support for the Is null and Is not null modifiers for all cases of the MultiCriterionInput and HierarchicalMultiCriterionInput. This partially overlaps the "X Count" filter which sometimes is available (because it would be the same as "X Count equals 0" and "X Count greater than 0") but this also enables it for other criterions like the "Parent Studio" filter for studios or just the "Studios" filter for scenes / images / galleries, the "Movies" filter for scenes etc. * Don't crash UI on bad saved filter * Add missing code for tag parent/child Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- pkg/sqlite/filter.go | 99 +++++++++++-- pkg/sqlite/gallery.go | 19 ++- pkg/sqlite/gallery_test.go | 23 +++ pkg/sqlite/image.go | 19 ++- pkg/sqlite/image_test.go | 99 +++++++++---- pkg/sqlite/movies.go | 19 ++- pkg/sqlite/performer.go | 50 +++++-- pkg/sqlite/performer_test.go | 28 ++++ pkg/sqlite/scene.go | 19 ++- pkg/sqlite/scene_marker.go | 35 ++++- pkg/sqlite/scene_marker_test.go | 136 +++++++++++++++++- pkg/sqlite/scene_test.go | 29 +++- pkg/sqlite/setup_test.go | 64 ++++++--- pkg/sqlite/tag.go | 36 ++++- pkg/sqlite/tag_test.go | 65 +++++++-- .../components/Changelog/versions/v0110.md | 1 + ui/v2.5/src/hooks/ListHook.tsx | 11 +- .../models/list-filter/criteria/criterion.ts | 2 + 18 files changed, 659 insertions(+), 95 deletions(-) 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;