More performer filter criteria (#179)

* Add new performer filter criteria to UI

* Add backend support for new performer criteria
This commit is contained in:
WithoutPants 2019-11-07 15:36:48 +11:00 committed by Leopere
parent c0911f1626
commit 3c089dd97c
7 changed files with 478 additions and 43 deletions

View file

@ -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!

View file

@ -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

View file

@ -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

View file

@ -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<IAddFilterProps> = (props: IAddFilterP
const [isOpen, setIsOpen] = useState(false);
const [criterion, setCriterion] = useState<Criterion<any, any>>(new NoneCriterion());
const valueStage = useRef<any>(criterion.value);
// Configure if we are editing an existing criterion
useEffect(() => {
if (!props.editingCriterion) { return; }
@ -56,10 +59,26 @@ export const AddFilter: FunctionComponent<IAddFilterProps> = (props: IAddFilterP
setCriterion(newCriterion);
}
function onChangedInput(event: React.ChangeEvent<HTMLInputElement>) {
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<IAddFilterProps> = (props: IAddFilterP
);
}
} else {
return (
<HTMLSelect
ref={singleValueSelect}
options={criterion.options}
onChange={onChangedSingleSelect}
defaultValue={criterion.value}
/>
);
if (criterion.options) {
return (
<HTMLSelect
ref={singleValueSelect}
options={criterion.options}
onChange={onChangedSingleSelect}
defaultValue={criterion.value}
/>
);
} else {
return (
<InputGroup
type={criterion.inputType}
onChange={onChangedInput}
onBlur={onBlurInput}
defaultValue={criterion.value ? criterion.value : ""}
/>
)
}
}
}
return (

View file

@ -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<Option = any, Value = any> {
public static getLabel(type: CriterionType = "none"): string {
@ -27,6 +39,18 @@ export abstract class Criterion<Option = any, Value = any> {
case "sceneTags": return "Scene Tags";
case "performers": return "Performers";
case "studios": return "Studios";
case "birth_year": return "Birth Year";
case "age": return "Age";
case "ethnicity": return "Ethnicity";
case "country": return "Country";
case "eye_color": return "Eye Color";
case "height": return "Height";
case "measurements": return "Measurements";
case "fake_tits": return "Fake Tits";
case "career_length": return "Career Length";
case "tattoos": return "Tattoos";
case "piercings": return "Piercings";
case "aliases": return "Aliases";
}
}
@ -48,8 +72,9 @@ export abstract class Criterion<Option = any, Value = any> {
public abstract parameterName: string;
public abstract modifier: CriterionModifier;
public abstract modifierOptions: ILabeledValue[];
public abstract options: Option[];
public abstract options: Option[] | undefined;
public abstract value: Value;
public inputType: "number" | "text" | undefined;
public getLabel(): string {
let modifierString: string;
@ -102,3 +127,71 @@ export interface ICriterionOption {
label: string;
value: CriterionType;
}
export class CriterionOption implements ICriterionOption {
public label: string;
public value: CriterionType;
constructor(label : string, value : CriterionType) {
this.label = label;
this.value = value;
}
}
export class StringCriterion extends Criterion<string, string> {
public type: CriterionType;
public parameterName: string;
public modifier = CriterionModifier.Equals;
public modifierOptions = [
Criterion.getModifierOption(CriterionModifier.Equals),
Criterion.getModifierOption(CriterionModifier.NotEquals),
Criterion.getModifierOption(CriterionModifier.IsNull),
Criterion.getModifierOption(CriterionModifier.NotNull),
];
public options: string[] | undefined;
public value: string = "";
constructor(type : CriterionType, parameterName?: string, options? : string[]) {
super();
this.type = type;
this.options = options;
this.inputType = "text";
if (!!parameterName) {
this.parameterName = parameterName;
} else {
this.parameterName = type;
}
}
}
export class NumberCriterion extends Criterion<number, number> {
public type: CriterionType;
public parameterName: string;
public modifier = CriterionModifier.Equals;
public modifierOptions = [
Criterion.getModifierOption(CriterionModifier.Equals),
Criterion.getModifierOption(CriterionModifier.NotEquals),
Criterion.getModifierOption(CriterionModifier.GreaterThan),
Criterion.getModifierOption(CriterionModifier.LessThan),
Criterion.getModifierOption(CriterionModifier.IsNull),
Criterion.getModifierOption(CriterionModifier.NotNull),
];
public options: number[] | undefined;
public value: number = 0;
constructor(type : CriterionType, parameterName?: string, options? : number[]) {
super();
this.type = type;
this.options = options;
this.inputType = "number";
if (!!parameterName) {
this.parameterName = parameterName;
} else {
this.parameterName = type;
}
}
}

View file

@ -1,12 +1,7 @@
import { QueryHookResult } from "react-apollo-hooks";
import {
AllPerformersForFilterQuery,
AllPerformersForFilterVariables,
AllTagsForFilterQuery,
AllTagsForFilterVariables,
CriterionModifier,
} from "../../../core/generated-graphql";
import { StashService } from "../../../core/StashService";
import { Criterion, CriterionType } from "./criterion";
import { Criterion, CriterionType, StringCriterion, NumberCriterion } from "./criterion";
import { FavoriteCriterion } from "./favorite";
import { HasMarkersCriterion } from "./has-markers";
import { IsMissingCriterion } from "./is-missing";
@ -29,5 +24,28 @@ export function makeCriteria(type: CriterionType = "none") {
case "sceneTags": return new TagsCriterion("sceneTags");
case "performers": return new PerformersCriterion();
case "studios": return new StudiosCriterion();
case "birth_year":
case "age":
var ret = new NumberCriterion(type, type);
// null/not null doesn't make sense for these criteria
ret.modifierOptions = [
Criterion.getModifierOption(CriterionModifier.Equals),
Criterion.getModifierOption(CriterionModifier.NotEquals),
Criterion.getModifierOption(CriterionModifier.GreaterThan),
Criterion.getModifierOption(CriterionModifier.LessThan)
];
return ret;
case "ethnicity":
case "country":
case "eye_color":
case "height":
case "measurements":
case "fake_tits":
case "career_length":
case "tattoos":
case "piercings":
case "aliases":
return new StringCriterion(type, type);
}
}

View file

@ -7,7 +7,7 @@ import {
SceneMarkerFilterType,
SortDirectionEnum,
} from "../../core/generated-graphql";
import { Criterion, ICriterionOption } from "./criteria/criterion";
import { Criterion, ICriterionOption, CriterionType, CriterionOption, NumberCriterion, StringCriterion } from "./criteria/criterion";
import { FavoriteCriterion, FavoriteCriterionOption } from "./criteria/favorite";
import { HasMarkersCriterion, HasMarkersCriterionOption } from "./criteria/has-markers";
import { IsMissingCriterion, IsMissingCriterionOption } from "./criteria/is-missing";
@ -75,10 +75,29 @@ export class ListFilterModel {
DisplayMode.Grid,
DisplayMode.List,
];
var numberCriteria : CriterionType[] = ["birth_year", "age"];
var stringCriteria : CriterionType[] = [
"ethnicity",
"country",
"eye_color",
"height",
"measurements",
"fake_tits",
"career_length",
"tattoos",
"piercings",
"aliases"
];
this.criterionOptions = [
new NoneCriterionOption(),
new FavoriteCriterionOption(),
new FavoriteCriterionOption()
];
this.criterionOptions = this.criterionOptions.concat(numberCriteria.concat(stringCriteria).map((c) => {
return new CriterionOption(Criterion.getLabel(c), c);
}));
break;
case FilterMode.Studios:
if (!!this.sortBy === false) { this.sortBy = "name"; }
@ -245,6 +264,54 @@ export class ListFilterModel {
case "favorite":
result.filter_favorites = (criterion as FavoriteCriterion).value === "true";
break;
case "birth_year":
const byCrit = criterion as NumberCriterion;
result.birth_year = { value: byCrit.value, modifier: byCrit.modifier };
break;
case "age":
const ageCrit = criterion as NumberCriterion;
result.age = { value: ageCrit.value, modifier: ageCrit.modifier };
break;
case "ethnicity":
const ethCrit = criterion as StringCriterion;
result.ethnicity = { value: ethCrit.value, modifier: ethCrit.modifier };
break;
case "country":
const cntryCrit = criterion as StringCriterion;
result.country = { value: cntryCrit.value, modifier: cntryCrit.modifier };
break;
case "eye_color":
const ecCrit = criterion as StringCriterion;
result.eye_color = { value: ecCrit.value, modifier: ecCrit.modifier };
break;
case "height":
const hCrit = criterion as StringCriterion;
result.height = { value: hCrit.value, modifier: hCrit.modifier };
break;
case "measurements":
const mCrit = criterion as StringCriterion;
result.measurements = { value: mCrit.value, modifier: mCrit.modifier };
break;
case "fake_tits":
const ftCrit = criterion as StringCriterion;
result.fake_tits = { value: ftCrit.value, modifier: ftCrit.modifier };
break;
case "career_length":
const clCrit = criterion as StringCriterion;
result.career_length = { value: clCrit.value, modifier: clCrit.modifier };
break;
case "tattoos":
const tCrit = criterion as StringCriterion;
result.tattoos = { value: tCrit.value, modifier: tCrit.modifier };
break;
case "piercings":
const pCrit = criterion as StringCriterion;
result.piercings = { value: pCrit.value, modifier: pCrit.modifier };
break;
case "aliases":
const aCrit = criterion as StringCriterion;
result.aliases = { value: aCrit.value, modifier: aCrit.modifier };
break;
}
});
return result;