Add penis length and circumcision stats to performers. (#3627)

* Add penis length stat to performers.
* Modified the UI to display and edit the stat.
* Added the ability to filter floats to allow filtering by penis length.
* Add circumcision stat to performer.
* Refactor enum filtering
* Change boolean filter to radio buttons
* Return null for empty enum values
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
departure18
2023-05-24 04:19:35 +01:00
committed by GitHub
parent 58a6c22072
commit 776c7e6c35
52 changed files with 1051 additions and 184 deletions

View File

@@ -32,7 +32,7 @@ const (
dbConnTimeout = 30
)
var appSchemaVersion uint = 45
var appSchemaVersion uint = 46
//go:embed migrations/*.sql
var migrationsBox embed.FS

View File

@@ -426,6 +426,29 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite
}
}
func enumCriterionHandler(modifier models.CriterionModifier, values []string, column string) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if modifier.IsValid() {
switch modifier {
case models.CriterionModifierIncludes, models.CriterionModifierEquals:
if len(values) > 0 {
f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, false))
}
case models.CriterionModifierExcludes, models.CriterionModifierNotEquals:
if len(values) > 0 {
f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, true))
}
case models.CriterionModifierIsNull:
f.addWhere("(" + column + " IS NULL OR TRIM(" + column + ") = '')")
case models.CriterionModifierNotNull:
f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')")
default:
panic("unsupported string filter modifier")
}
}
}
}
func pathCriterionHandler(c *models.StringCriterionInput, pathColumn string, basenameColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if c != nil {
@@ -525,6 +548,15 @@ func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn f
}
}
func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if c != nil {
clause, args := getFloatCriterionWhereClause(column, *c)
f.addWhere(clause, args...)
}
}
}
func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if c != nil {

View File

@@ -0,0 +1,2 @@
ALTER TABLE `performers` ADD COLUMN `penis_length` float;
ALTER TABLE `performers` ADD COLUMN `circumcised` varchar[10];

View File

@@ -42,6 +42,8 @@ type performerRow struct {
Height null.Int `db:"height"`
Measurements zero.String `db:"measurements"`
FakeTits zero.String `db:"fake_tits"`
PenisLength null.Float `db:"penis_length"`
Circumcised zero.String `db:"circumcised"`
CareerLength zero.String `db:"career_length"`
Tattoos zero.String `db:"tattoos"`
Piercings zero.String `db:"piercings"`
@@ -64,7 +66,7 @@ func (r *performerRow) fromPerformer(o models.Performer) {
r.ID = o.ID
r.Name = o.Name
r.Disambigation = zero.StringFrom(o.Disambiguation)
if o.Gender.IsValid() {
if o.Gender != nil && o.Gender.IsValid() {
r.Gender = zero.StringFrom(o.Gender.String())
}
r.URL = zero.StringFrom(o.URL)
@@ -79,6 +81,10 @@ func (r *performerRow) fromPerformer(o models.Performer) {
r.Height = intFromPtr(o.Height)
r.Measurements = zero.StringFrom(o.Measurements)
r.FakeTits = zero.StringFrom(o.FakeTits)
r.PenisLength = null.FloatFromPtr(o.PenisLength)
if o.Circumcised != nil && o.Circumcised.IsValid() {
r.Circumcised = zero.StringFrom(o.Circumcised.String())
}
r.CareerLength = zero.StringFrom(o.CareerLength)
r.Tattoos = zero.StringFrom(o.Tattoos)
r.Piercings = zero.StringFrom(o.Piercings)
@@ -100,7 +106,6 @@ func (r *performerRow) resolve() *models.Performer {
ID: r.ID,
Name: r.Name,
Disambiguation: r.Disambigation.String,
Gender: models.GenderEnum(r.Gender.String),
URL: r.URL.String,
Twitter: r.Twitter.String,
Instagram: r.Instagram.String,
@@ -111,6 +116,7 @@ func (r *performerRow) resolve() *models.Performer {
Height: nullIntPtr(r.Height),
Measurements: r.Measurements.String,
FakeTits: r.FakeTits.String,
PenisLength: nullFloatPtr(r.PenisLength),
CareerLength: r.CareerLength.String,
Tattoos: r.Tattoos.String,
Piercings: r.Piercings.String,
@@ -126,6 +132,16 @@ func (r *performerRow) resolve() *models.Performer {
IgnoreAutoTag: r.IgnoreAutoTag,
}
if r.Gender.ValueOrZero() != "" {
v := models.GenderEnum(r.Gender.String)
ret.Gender = &v
}
if r.Circumcised.ValueOrZero() != "" {
v := models.CircumisedEnum(r.Circumcised.String)
ret.Circumcised = &v
}
return ret
}
@@ -147,6 +163,8 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) {
r.setNullInt("height", o.Height)
r.setNullString("measurements", o.Measurements)
r.setNullString("fake_tits", o.FakeTits)
r.setNullFloat64("penis_length", o.PenisLength)
r.setNullString("circumcised", o.Circumcised)
r.setNullString("career_length", o.CareerLength)
r.setNullString("tattoos", o.Tattoos)
r.setNullString("piercings", o.Piercings)
@@ -597,6 +615,15 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform
query.handleCriterion(ctx, stringCriterionHandler(filter.Measurements, tableName+".measurements"))
query.handleCriterion(ctx, stringCriterionHandler(filter.FakeTits, tableName+".fake_tits"))
query.handleCriterion(ctx, floatCriterionHandler(filter.PenisLength, tableName+".penis_length", nil))
query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {
if circumcised := filter.Circumcised; circumcised != nil {
v := utils.StringerSliceToStringSlice(circumcised.Value)
enumCriterionHandler(circumcised.Modifier, v, tableName+".circumcised")(ctx, f)
}
}))
query.handleCriterion(ctx, stringCriterionHandler(filter.CareerLength, tableName+".career_length"))
query.handleCriterion(ctx, stringCriterionHandler(filter.Tattoos, tableName+".tattoos"))
query.handleCriterion(ctx, stringCriterionHandler(filter.Piercings, tableName+".piercings"))

View File

@@ -52,6 +52,8 @@ func Test_PerformerStore_Create(t *testing.T) {
height = 134
measurements = "measurements"
fakeTits = "fakeTits"
penisLength = 1.23
circumcised = models.CircumisedEnumCut
careerLength = "careerLength"
tattoos = "tattoos"
piercings = "piercings"
@@ -81,7 +83,7 @@ func Test_PerformerStore_Create(t *testing.T) {
models.Performer{
Name: name,
Disambiguation: disambiguation,
Gender: gender,
Gender: &gender,
URL: url,
Twitter: twitter,
Instagram: instagram,
@@ -92,6 +94,8 @@ func Test_PerformerStore_Create(t *testing.T) {
Height: &height,
Measurements: measurements,
FakeTits: fakeTits,
PenisLength: &penisLength,
Circumcised: &circumcised,
CareerLength: careerLength,
Tattoos: tattoos,
Piercings: piercings,
@@ -196,6 +200,8 @@ func Test_PerformerStore_Update(t *testing.T) {
height = 134
measurements = "measurements"
fakeTits = "fakeTits"
penisLength = 1.23
circumcised = models.CircumisedEnumCut
careerLength = "careerLength"
tattoos = "tattoos"
piercings = "piercings"
@@ -226,7 +232,7 @@ func Test_PerformerStore_Update(t *testing.T) {
ID: performerIDs[performerIdxWithGallery],
Name: name,
Disambiguation: disambiguation,
Gender: gender,
Gender: &gender,
URL: url,
Twitter: twitter,
Instagram: instagram,
@@ -237,6 +243,8 @@ func Test_PerformerStore_Update(t *testing.T) {
Height: &height,
Measurements: measurements,
FakeTits: fakeTits,
PenisLength: &penisLength,
Circumcised: &circumcised,
CareerLength: careerLength,
Tattoos: tattoos,
Piercings: piercings,
@@ -327,6 +335,7 @@ func clearPerformerPartial() models.PerformerPartial {
nullString := models.OptionalString{Set: true, Null: true}
nullDate := models.OptionalDate{Set: true, Null: true}
nullInt := models.OptionalInt{Set: true, Null: true}
nullFloat := models.OptionalFloat64{Set: true, Null: true}
// leave mandatory fields
return models.PerformerPartial{
@@ -342,6 +351,8 @@ func clearPerformerPartial() models.PerformerPartial {
Height: nullInt,
Measurements: nullString,
FakeTits: nullString,
PenisLength: nullFloat,
Circumcised: nullString,
CareerLength: nullString,
Tattoos: nullString,
Piercings: nullString,
@@ -372,6 +383,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
height = 143
measurements = "measurements"
fakeTits = "fakeTits"
penisLength = 1.23
circumcised = models.CircumisedEnumCut
careerLength = "careerLength"
tattoos = "tattoos"
piercings = "piercings"
@@ -415,6 +428,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
Height: models.NewOptionalInt(height),
Measurements: models.NewOptionalString(measurements),
FakeTits: models.NewOptionalString(fakeTits),
PenisLength: models.NewOptionalFloat64(penisLength),
Circumcised: models.NewOptionalString(circumcised.String()),
CareerLength: models.NewOptionalString(careerLength),
Tattoos: models.NewOptionalString(tattoos),
Piercings: models.NewOptionalString(piercings),
@@ -453,7 +468,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
ID: performerIDs[performerIdxWithDupName],
Name: name,
Disambiguation: disambiguation,
Gender: gender,
Gender: &gender,
URL: url,
Twitter: twitter,
Instagram: instagram,
@@ -464,6 +479,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
Height: &height,
Measurements: measurements,
FakeTits: fakeTits,
PenisLength: &penisLength,
Circumcised: &circumcised,
CareerLength: careerLength,
Tattoos: tattoos,
Piercings: piercings,
@@ -957,16 +974,30 @@ func TestPerformerQuery(t *testing.T) {
false,
},
{
"alias",
"circumcised (cut)",
nil,
&models.PerformerFilterType{
Aliases: &models.StringCriterionInput{
Value: getPerformerStringValue(performerIdxWithGallery, "alias"),
Modifier: models.CriterionModifierEquals,
Circumcised: &models.CircumcisionCriterionInput{
Value: []models.CircumisedEnum{models.CircumisedEnumCut},
Modifier: models.CriterionModifierIncludes,
},
},
[]int{performerIdxWithGallery},
[]int{performerIdxWithScene},
[]int{performerIdx1WithScene},
[]int{performerIdxWithScene, performerIdx2WithScene},
false,
},
{
"circumcised (excludes cut)",
nil,
&models.PerformerFilterType{
Circumcised: &models.CircumcisionCriterionInput{
Value: []models.CircumisedEnum{models.CircumisedEnumCut},
Modifier: models.CriterionModifierExcludes,
},
},
[]int{performerIdx2WithScene},
// performerIdxWithScene has null value
[]int{performerIdx1WithScene, performerIdxWithScene},
false,
},
}
@@ -995,6 +1026,107 @@ func TestPerformerQuery(t *testing.T) {
}
}
func TestPerformerQueryPenisLength(t *testing.T) {
var upper = 4.0
tests := []struct {
name string
modifier models.CriterionModifier
value float64
value2 *float64
}{
{
"equals",
models.CriterionModifierEquals,
1,
nil,
},
{
"not equals",
models.CriterionModifierNotEquals,
1,
nil,
},
{
"greater than",
models.CriterionModifierGreaterThan,
1,
nil,
},
{
"between",
models.CriterionModifierBetween,
2,
&upper,
},
{
"greater than",
models.CriterionModifierNotBetween,
2,
&upper,
},
{
"null",
models.CriterionModifierIsNull,
0,
nil,
},
{
"not null",
models.CriterionModifierNotNull,
0,
nil,
},
}
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
filter := &models.PerformerFilterType{
PenisLength: &models.FloatCriterionInput{
Modifier: tt.modifier,
Value: tt.value,
Value2: tt.value2,
},
}
performers, _, err := db.Performer.Query(ctx, filter, nil)
if err != nil {
t.Errorf("PerformerStore.Query() error = %v", err)
return
}
for _, p := range performers {
verifyFloat(t, p.PenisLength, *filter.PenisLength)
}
})
}
}
func verifyFloat(t *testing.T, value *float64, criterion models.FloatCriterionInput) bool {
t.Helper()
assert := assert.New(t)
switch criterion.Modifier {
case models.CriterionModifierEquals:
return assert.NotNil(value) && assert.Equal(criterion.Value, *value)
case models.CriterionModifierNotEquals:
return assert.NotNil(value) && assert.NotEqual(criterion.Value, *value)
case models.CriterionModifierGreaterThan:
return assert.NotNil(value) && assert.Greater(*value, criterion.Value)
case models.CriterionModifierLessThan:
return assert.NotNil(value) && assert.Less(*value, criterion.Value)
case models.CriterionModifierBetween:
return assert.NotNil(value) && assert.GreaterOrEqual(*value, criterion.Value) && assert.LessOrEqual(*value, *criterion.Value2)
case models.CriterionModifierNotBetween:
return assert.NotNil(value) && assert.True(*value < criterion.Value || *value > *criterion.Value2)
case models.CriterionModifierIsNull:
return assert.Nil(value)
case models.CriterionModifierNotNull:
return assert.NotNil(value)
}
return false
}
func TestPerformerQueryForAutoTag(t *testing.T) {
withTxn(func(ctx context.Context) error {
tqb := db.Performer

View File

@@ -3,6 +3,7 @@ package sqlite
import (
"github.com/doug-martin/goqu/v9/exp"
"github.com/stashapp/stash/pkg/models"
"gopkg.in/guregu/null.v4"
"gopkg.in/guregu/null.v4/zero"
)
@@ -77,11 +78,11 @@ func (r *updateRecord) setFloat64(destField string, v models.OptionalFloat64) {
}
}
// func (r *updateRecord) setNullFloat64(destField string, v models.OptionalFloat64) {
// if v.Set {
// r.set(destField, null.FloatFromPtr(v.Ptr()))
// }
// }
func (r *updateRecord) setNullFloat64(destField string, v models.OptionalFloat64) {
if v.Set {
r.set(destField, null.FloatFromPtr(v.Ptr()))
}
}
func (r *updateRecord) setSQLiteTimestamp(destField string, v models.OptionalTime) {
if v.Set {

View File

@@ -1331,6 +1331,29 @@ func getPerformerCareerLength(index int) *string {
return &ret
}
func getPerformerPenisLength(index int) *float64 {
if index%5 == 0 {
return nil
}
ret := float64(index)
return &ret
}
func getPerformerCircumcised(index int) *models.CircumisedEnum {
var ret models.CircumisedEnum
switch {
case index%3 == 0:
return nil
case index%3 == 1:
ret = models.CircumisedEnumCut
default:
ret = models.CircumisedEnumUncut
}
return &ret
}
func getIgnoreAutoTag(index int) bool {
return index%5 == 0
}
@@ -1372,6 +1395,8 @@ func createPerformers(ctx context.Context, n int, o int) error {
DeathDate: getPerformerDeathDate(i),
Details: getPerformerStringValue(i, "Details"),
Ethnicity: getPerformerStringValue(i, "Ethnicity"),
PenisLength: getPerformerPenisLength(i),
Circumcised: getPerformerCircumcised(i),
Rating: getIntPtr(getRating(i)),
IgnoreAutoTag: getIgnoreAutoTag(i),
TagIDs: models.NewRelatedIDs(tids),

View File

@@ -159,6 +159,22 @@ func getStringSearchClause(columns []string, q string, not bool) sqlClause {
return makeClause("("+likes+")", args...)
}
func getEnumSearchClause(column string, enumVals []string, not bool) sqlClause {
var args []interface{}
notStr := ""
if not {
notStr = " NOT"
}
clause := fmt.Sprintf("(%s%s IN %s)", column, notStr, getInBinding(len(enumVals)))
for _, enumVal := range enumVals {
args = append(args, enumVal)
}
return makeClause(clause, args...)
}
func getInBinding(length int) string {
bindings := strings.Repeat("?, ", length)
bindings = strings.TrimRight(bindings, ", ")
@@ -175,8 +191,26 @@ func getIntWhereClause(column string, modifier models.CriterionModifier, value i
upper = &u
}
args := []interface{}{value}
betweenArgs := []interface{}{value, *upper}
args := []interface{}{value, *upper}
return getNumericWhereClause(column, modifier, args)
}
func getFloatCriterionWhereClause(column string, input models.FloatCriterionInput) (string, []interface{}) {
return getFloatWhereClause(column, input.Modifier, input.Value, input.Value2)
}
func getFloatWhereClause(column string, modifier models.CriterionModifier, value float64, upper *float64) (string, []interface{}) {
if upper == nil {
u := 0.0
upper = &u
}
args := []interface{}{value, *upper}
return getNumericWhereClause(column, modifier, args)
}
func getNumericWhereClause(column string, modifier models.CriterionModifier, args []interface{}) (string, []interface{}) {
singleArgs := args[0:1]
switch modifier {
case models.CriterionModifierIsNull:
@@ -184,20 +218,20 @@ func getIntWhereClause(column string, modifier models.CriterionModifier, value i
case models.CriterionModifierNotNull:
return fmt.Sprintf("%s IS NOT NULL", column), nil
case models.CriterionModifierEquals:
return fmt.Sprintf("%s = ?", column), args
return fmt.Sprintf("%s = ?", column), singleArgs
case models.CriterionModifierNotEquals:
return fmt.Sprintf("%s != ?", column), args
return fmt.Sprintf("%s != ?", column), singleArgs
case models.CriterionModifierBetween:
return fmt.Sprintf("%s BETWEEN ? AND ?", column), betweenArgs
return fmt.Sprintf("%s BETWEEN ? AND ?", column), args
case models.CriterionModifierNotBetween:
return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), betweenArgs
return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), args
case models.CriterionModifierLessThan:
return fmt.Sprintf("%s < ?", column), args
return fmt.Sprintf("%s < ?", column), singleArgs
case models.CriterionModifierGreaterThan:
return fmt.Sprintf("%s > ?", column), args
return fmt.Sprintf("%s > ?", column), singleArgs
}
panic("unsupported int modifier type " + modifier)
panic("unsupported numeric modifier type " + modifier)
}
func getDateCriterionWhereClause(column string, input models.DateCriterionInput) (string, []interface{}) {

View File

@@ -24,6 +24,15 @@ func nullIntPtr(i null.Int) *int {
return &v
}
func nullFloatPtr(i null.Float) *float64 {
if !i.Valid {
return nil
}
v := float64(i.Float64)
return &v
}
func nullIntFolderIDPtr(i null.Int) *file.FolderID {
if !i.Valid {
return nil