From 3c089dd97c4804e83572b81b55deb1f45ec4e6c0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 7 Nov 2019 15:36:48 +1100 Subject: [PATCH] More performer filter criteria (#179) * Add new performer filter criteria to UI * Add backend support for new performer criteria --- graphql/schema/types/filters.graphql | 29 ++++ pkg/models/querybuilder_performer.go | 144 ++++++++++++++++-- pkg/models/querybuilder_sql.go | 100 ++++++++++-- ui/v2/src/components/list/AddFilter.tsx | 48 ++++-- .../models/list-filter/criteria/criterion.ts | 97 +++++++++++- .../src/models/list-filter/criteria/utils.ts | 32 +++- ui/v2/src/models/list-filter/filter.ts | 71 ++++++++- 7 files changed, 478 insertions(+), 43 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 58c364c47..600e871ee 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -22,6 +22,30 @@ enum ResolutionEnum { input PerformerFilterType { """Filter by favorite""" filter_favorites: Boolean + """Filter by birth year""" + birth_year: IntCriterionInput + """Filter by age""" + age: IntCriterionInput + """Filter by ethnicity""" + ethnicity: StringCriterionInput + """Filter by country""" + country: StringCriterionInput + """Filter by eye color""" + eye_color: StringCriterionInput + """Filter by height""" + height: StringCriterionInput + """Filter by measurements""" + measurements: StringCriterionInput + """Filter by fake tits value""" + fake_tits: StringCriterionInput + """Filter by career length""" + career_length: StringCriterionInput + """Filter by tattoos""" + tattoos: StringCriterionInput + """Filter by piercings""" + piercings: StringCriterionInput + """Filter by aliases""" + aliases: StringCriterionInput } input SceneMarkerFilterType { @@ -71,6 +95,11 @@ enum CriterionModifier { EXCLUDES, } +input StringCriterionInput { + value: String! + modifier: CriterionModifier! +} + input IntCriterionInput { value: Int! modifier: CriterionModifier! diff --git a/pkg/models/querybuilder_performer.go b/pkg/models/querybuilder_performer.go index d19965bb2..7cdb10093 100644 --- a/pkg/models/querybuilder_performer.go +++ b/pkg/models/querybuilder_performer.go @@ -2,6 +2,8 @@ package models import ( "database/sql" + "strconv" + "time" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/database" @@ -111,30 +113,60 @@ func (qb *PerformerQueryBuilder) Query(performerFilter *PerformerFilterType, fin findFilter = &FindFilterType{} } - var whereClauses []string - var havingClauses []string - var args []interface{} - body := selectDistinctIDs("performers") - body += ` + query := queryBuilder{ + tableName: "performers", + } + + query.body = selectDistinctIDs("performers") + query.body += ` left join performers_scenes as scenes_join on scenes_join.performer_id = performers.id left join scenes on scenes_join.scene_id = scenes.id ` if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"performers.name", "performers.checksum", "performers.birthdate", "performers.ethnicity"} - whereClauses = append(whereClauses, getSearch(searchColumns, *q)) + clause, thisArgs := getSearchBinding(searchColumns, *q, false) + query.addWhere(clause) + query.addArg(thisArgs...) } if favoritesFilter := performerFilter.FilterFavorites; favoritesFilter != nil { + var favStr string if *favoritesFilter == true { - whereClauses = append(whereClauses, "performers.favorite = 1") + favStr = "1" } else { - whereClauses = append(whereClauses, "performers.favorite = 0") + favStr = "0" } + query.addWhere("performers.favorite = " + favStr) } - sortAndPagination := qb.getPerformerSort(findFilter) + getPagination(findFilter) - idsResult, countResult := executeFindQuery("performers", body, args, sortAndPagination, whereClauses, havingClauses) + if birthYear := performerFilter.BirthYear; birthYear != nil { + clauses, thisArgs := getBirthYearFilterClause(birthYear.Modifier, birthYear.Value) + query.addWhere(clauses...) + query.addArg(thisArgs...) + } + + if age := performerFilter.Age; age != nil { + clauses, thisArgs := getAgeFilterClause(age.Modifier, age.Value) + query.addWhere(clauses...) + query.addArg(thisArgs...) + } + + handleStringCriterion("ethnicity", performerFilter.Ethnicity, &query) + handleStringCriterion("country", performerFilter.Country, &query) + handleStringCriterion("eye_color", performerFilter.EyeColor, &query) + handleStringCriterion("height", performerFilter.Height, &query) + handleStringCriterion("measurements", performerFilter.Measurements, &query) + handleStringCriterion("fake_tits", performerFilter.FakeTits, &query) + handleStringCriterion("career_length", performerFilter.CareerLength, &query) + handleStringCriterion("tattoos", performerFilter.Tattoos, &query) + handleStringCriterion("piercings", performerFilter.Piercings, &query) + + // TODO - need better handling of aliases + handleStringCriterion("aliases", performerFilter.Aliases, &query) + + query.sortAndPagination = qb.getPerformerSort(findFilter) + getPagination(findFilter) + idsResult, countResult := query.executeFind() var performers []*Performer for _, id := range idsResult { @@ -145,6 +177,98 @@ func (qb *PerformerQueryBuilder) Query(performerFilter *PerformerFilterType, fin return performers, countResult } +func handleStringCriterion(column string, value *StringCriterionInput, query *queryBuilder) { + if value != nil { + if modifier := value.Modifier.String(); value.Modifier.IsValid() { + switch modifier { + case "EQUALS": + clause, thisArgs := getSearchBinding([]string{column}, value.Value, false) + query.addWhere(clause) + query.addArg(thisArgs...) + case "NOT_EQUALS": + clause, thisArgs := getSearchBinding([]string{column}, value.Value, true) + query.addWhere(clause) + query.addArg(thisArgs...) + case "IS_NULL": + query.addWhere(column + " IS NULL") + case "NOT_NULL": + query.addWhere(column + " IS NOT NULL") + } + } + } +} + +func getBirthYearFilterClause(criterionModifier CriterionModifier, value int) ([]string, []interface{}) { + var clauses []string + var args []interface{} + + yearStr := strconv.Itoa(value) + startOfYear := yearStr + "-01-01" + endOfYear := yearStr + "-12-31" + + if modifier := criterionModifier.String(); criterionModifier.IsValid() { + switch modifier { + case "EQUALS": + // between yyyy-01-01 and yyyy-12-31 + clauses = append(clauses, "performers.birthdate >= ?") + clauses = append(clauses, "performers.birthdate <= ?") + args = append(args, startOfYear) + args = append(args, endOfYear) + case "NOT_EQUALS": + // outside of yyyy-01-01 to yyyy-12-31 + clauses = append(clauses, "performers.birthdate < ? OR performers.birthdate > ?") + args = append(args, startOfYear) + args = append(args, endOfYear) + case "GREATER_THAN": + // > yyyy-12-31 + clauses = append(clauses, "performers.birthdate > ?") + args = append(args, endOfYear) + case "LESS_THAN": + // < yyyy-01-01 + clauses = append(clauses, "performers.birthdate < ?") + args = append(args, startOfYear) + } + } + + return clauses, args +} + +func getAgeFilterClause(criterionModifier CriterionModifier, value int) ([]string, []interface{}) { + var clauses []string + var args []interface{} + + // get the date at which performer would turn the age specified + dt := time.Now() + birthDate := dt.AddDate(-value-1, 0, 0) + yearAfter := birthDate.AddDate(1, 0, 0) + + if modifier := criterionModifier.String(); criterionModifier.IsValid() { + switch modifier { + case "EQUALS": + // between birthDate and yearAfter + clauses = append(clauses, "performers.birthdate >= ?") + clauses = append(clauses, "performers.birthdate < ?") + args = append(args, birthDate) + args = append(args, yearAfter) + case "NOT_EQUALS": + // outside of birthDate and yearAfter + clauses = append(clauses, "performers.birthdate < ? OR performers.birthdate >= ?") + args = append(args, birthDate) + args = append(args, yearAfter) + case "GREATER_THAN": + // < birthDate + clauses = append(clauses, "performers.birthdate < ?") + args = append(args, birthDate) + case "LESS_THAN": + // > yearAfter + clauses = append(clauses, "performers.birthdate >= ?") + args = append(args, yearAfter) + } + } + + return clauses, args +} + func (qb *PerformerQueryBuilder) getPerformerSort(findFilter *FindFilterType) string { var sort string var direction string diff --git a/pkg/models/querybuilder_sql.go b/pkg/models/querybuilder_sql.go index 59755837a..b3f75c2af 100644 --- a/pkg/models/querybuilder_sql.go +++ b/pkg/models/querybuilder_sql.go @@ -13,6 +13,33 @@ import ( "github.com/stashapp/stash/pkg/logger" ) +type queryBuilder struct { + tableName string + body string + + whereClauses []string + havingClauses []string + args []interface{} + + sortAndPagination string +} + +func (qb queryBuilder) executeFind() ([]int, int) { + return executeFindQuery(qb.tableName, qb.body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses) +} + +func (qb *queryBuilder) addWhere(clauses ...string) { + qb.whereClauses = append(qb.whereClauses, clauses...) +} + +func (qb *queryBuilder) addHaving(clauses ...string) { + qb.havingClauses = append(qb.havingClauses, clauses...) +} + +func (qb *queryBuilder) addArg(args ...interface{}) { + qb.args = append(qb.args, args...) +} + var randomSortFloat = rand.Float64() func selectAll(tableName string) string { @@ -92,6 +119,7 @@ func getSort(sort string, direction string, tableName string) string { } func getSearch(columns []string, q string) string { + // TODO - susceptible to SQL injection var likeClauses []string queryWords := strings.Split(q, " ") trimmedQuery := strings.Trim(q, "\"") @@ -113,6 +141,39 @@ func getSearch(columns []string, q string) string { return "(" + likes + ")" } +func getSearchBinding(columns []string, q string, not bool) (string, []interface{}) { + var likeClauses []string + var args []interface{} + + notStr := "" + binaryType := " OR " + if not { + notStr = " NOT " + binaryType = " AND " + } + + queryWords := strings.Split(q, " ") + trimmedQuery := strings.Trim(q, "\"") + if trimmedQuery == q { + // Search for any word + for _, word := range queryWords { + for _, column := range columns { + likeClauses = append(likeClauses, column+notStr+" LIKE ?") + args = append(args, "%"+word+"%") + } + } + } else { + // Search the exact query + for _, column := range columns { + likeClauses = append(likeClauses, column+notStr+" LIKE ?") + args = append(args, "%"+trimmedQuery+"%") + } + } + likes := strings.Join(likeClauses, binaryType) + + return "(" + likes + ")", args +} + func getInBinding(length int) string { bindings := strings.Repeat("?, ", length) bindings = strings.TrimRight(bindings, ", ") @@ -128,22 +189,11 @@ func getCriterionModifierBinding(criterionModifier CriterionModifier, value inte length = len(x) default: length = 1 - logger.Debugf("unsupported type: %T\n", x) } if modifier := criterionModifier.String(); criterionModifier.IsValid() { switch modifier { - case "EQUALS": - return "= ?", 1 - case "NOT_EQUALS": - return "!= ?", 1 - case "GREATER_THAN": - return "> ?", 1 - case "LESS_THAN": - return "< ?", 1 - case "IS_NULL": - return "IS NULL", 0 - case "NOT_NULL": - return "IS NOT NULL", 0 + case "EQUALS", "NOT_EQUALS", "GREATER_THAN", "LESS_THAN", "IS_NULL", "NOT_NULL": + return getSimpleCriterionClause(criterionModifier, "?") case "INCLUDES": return "IN " + getInBinding(length), length // TODO? case "EXCLUDES": @@ -156,6 +206,30 @@ func getCriterionModifierBinding(criterionModifier CriterionModifier, value inte return "= ?", 1 // TODO } +func getSimpleCriterionClause(criterionModifier CriterionModifier, rhs string) (string, int) { + if modifier := criterionModifier.String(); criterionModifier.IsValid() { + switch modifier { + case "EQUALS": + return "= " + rhs, 1 + case "NOT_EQUALS": + return "!= " + rhs, 1 + case "GREATER_THAN": + return "> " + rhs, 1 + case "LESS_THAN": + return "< " + rhs, 1 + case "IS_NULL": + return "IS NULL", 0 + case "NOT_NULL": + return "IS NOT NULL", 0 + default: + logger.Errorf("todo") + return "= ?", 1 // TODO + } + } + + return "= ?", 1 // TODO +} + func getIntCriterionWhereClause(column string, input IntCriterionInput) (string, int) { binding, count := getCriterionModifierBinding(input.Modifier, input.Value) return column + " " + binding, count diff --git a/ui/v2/src/components/list/AddFilter.tsx b/ui/v2/src/components/list/AddFilter.tsx index 17661a547..f7f2bf3a4 100644 --- a/ui/v2/src/components/list/AddFilter.tsx +++ b/ui/v2/src/components/list/AddFilter.tsx @@ -4,6 +4,7 @@ import { Dialog, FormGroup, HTMLSelect, + InputGroup, } from "@blueprintjs/core"; import _ from "lodash"; import React, { FunctionComponent, useEffect, useRef, useState } from "react"; @@ -31,6 +32,8 @@ export const AddFilter: FunctionComponent = (props: IAddFilterP const [isOpen, setIsOpen] = useState(false); const [criterion, setCriterion] = useState>(new NoneCriterion()); + const valueStage = useRef(criterion.value); + // Configure if we are editing an existing criterion useEffect(() => { if (!props.editingCriterion) { return; } @@ -56,10 +59,26 @@ export const AddFilter: FunctionComponent = (props: IAddFilterP setCriterion(newCriterion); } + function onChangedInput(event: React.ChangeEvent) { + valueStage.current = event.target.value; + } + + function onBlurInput() { + const newCriterion = _.cloneDeep(criterion); + newCriterion.value = valueStage.current; + setCriterion(newCriterion); + } + function onAddFilter() { if (!isArray(criterion.value) && !!singleValueSelect.current) { const value = singleValueSelect.current.props.defaultValue; - if (value === undefined || value === "" || typeof value === "number") { criterion.value = criterion.options[0]; } + if (criterion.options && (value === undefined || value === "" || typeof value === "number")) { + criterion.value = criterion.options[0]; + } else if (typeof value === "number" && value === undefined) { + criterion.value = 0; + } else if (value === undefined) { + criterion.value = ""; + } } const oldId = !!props.editingCriterion ? props.editingCriterion.getId() : undefined; props.onAddCriterion(criterion, oldId); @@ -119,14 +138,25 @@ export const AddFilter: FunctionComponent = (props: IAddFilterP ); } } else { - return ( - - ); + if (criterion.options) { + return ( + + ); + } else { + return ( + + ) + } } } return ( diff --git a/ui/v2/src/models/list-filter/criteria/criterion.ts b/ui/v2/src/models/list-filter/criteria/criterion.ts index ac7e538f9..a54df5245 100644 --- a/ui/v2/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2/src/models/list-filter/criteria/criterion.ts @@ -12,7 +12,19 @@ export type CriterionType = "tags" | "sceneTags" | "performers" | - "studios"; + "studios" | + "birth_year" | + "age" | + "ethnicity" | + "country" | + "eye_color" | + "height" | + "measurements" | + "fake_tits" | + "career_length" | + "tattoos" | + "piercings" | + "aliases"; export abstract class Criterion