package sqlite import ( "context" "database/sql" "errors" "fmt" "strconv" "strings" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil/intslice" "github.com/stashapp/stash/pkg/utils" "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" ) const ( performerTable = "performers" performerIDColumn = "performer_id" performersAliasesTable = "performer_aliases" performerAliasColumn = "alias" performersTagsTable = "performers_tags" performerImageBlobColumn = "image_blob" ) type performerRow struct { ID int `db:"id" goqu:"skipinsert"` Name null.String `db:"name"` // TODO: make schema non-nullable Disambigation zero.String `db:"disambiguation"` Gender zero.String `db:"gender"` URL zero.String `db:"url"` Twitter zero.String `db:"twitter"` Instagram zero.String `db:"instagram"` Birthdate NullDate `db:"birthdate"` Ethnicity zero.String `db:"ethnicity"` Country zero.String `db:"country"` EyeColor zero.String `db:"eye_color"` 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"` Favorite bool `db:"favorite"` CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"` // expressed as 1-100 Rating null.Int `db:"rating"` Details zero.String `db:"details"` DeathDate NullDate `db:"death_date"` HairColor zero.String `db:"hair_color"` Weight null.Int `db:"weight"` IgnoreAutoTag bool `db:"ignore_auto_tag"` // not used in resolution or updates ImageBlob zero.String `db:"image_blob"` } func (r *performerRow) fromPerformer(o models.Performer) { r.ID = o.ID r.Name = null.StringFrom(o.Name) r.Disambigation = zero.StringFrom(o.Disambiguation) if o.Gender != nil && o.Gender.IsValid() { r.Gender = zero.StringFrom(o.Gender.String()) } r.URL = zero.StringFrom(o.URL) r.Twitter = zero.StringFrom(o.Twitter) r.Instagram = zero.StringFrom(o.Instagram) r.Birthdate = NullDateFromDatePtr(o.Birthdate) r.Ethnicity = zero.StringFrom(o.Ethnicity) r.Country = zero.StringFrom(o.Country) r.EyeColor = zero.StringFrom(o.EyeColor) 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) r.Favorite = o.Favorite r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} r.Rating = intFromPtr(o.Rating) r.Details = zero.StringFrom(o.Details) r.DeathDate = NullDateFromDatePtr(o.DeathDate) r.HairColor = zero.StringFrom(o.HairColor) r.Weight = intFromPtr(o.Weight) r.IgnoreAutoTag = o.IgnoreAutoTag } func (r *performerRow) resolve() *models.Performer { ret := &models.Performer{ ID: r.ID, Name: r.Name.String, Disambiguation: r.Disambigation.String, URL: r.URL.String, Twitter: r.Twitter.String, Instagram: r.Instagram.String, Birthdate: r.Birthdate.DatePtr(), Ethnicity: r.Ethnicity.String, Country: r.Country.String, EyeColor: r.EyeColor.String, 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, Favorite: r.Favorite, CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, // expressed as 1-100 Rating: nullIntPtr(r.Rating), Details: r.Details.String, DeathDate: r.DeathDate.DatePtr(), HairColor: r.HairColor.String, Weight: nullIntPtr(r.Weight), 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 } type performerRowRecord struct { updateRecord } func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { r.setString("name", o.Name) r.setNullString("disambiguation", o.Disambiguation) r.setNullString("gender", o.Gender) r.setNullString("url", o.URL) r.setNullString("twitter", o.Twitter) r.setNullString("instagram", o.Instagram) r.setNullDate("birthdate", o.Birthdate) r.setNullString("ethnicity", o.Ethnicity) r.setNullString("country", o.Country) r.setNullString("eye_color", o.EyeColor) 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) r.setBool("favorite", o.Favorite) r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("updated_at", o.UpdatedAt) r.setNullInt("rating", o.Rating) r.setNullString("details", o.Details) r.setNullDate("death_date", o.DeathDate) r.setNullString("hair_color", o.HairColor) r.setNullInt("weight", o.Weight) r.setBool("ignore_auto_tag", o.IgnoreAutoTag) } type PerformerStore struct { repository blobJoinQueryBuilder tableMgr *table } func NewPerformerStore(blobStore *BlobStore) *PerformerStore { return &PerformerStore{ repository: repository{ tableName: performerTable, idColumn: idColumn, }, blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, joinTable: performerTable, }, tableMgr: performerTableMgr, } } func (qb *PerformerStore) table() exp.IdentifierExpression { return qb.tableMgr.table } func (qb *PerformerStore) selectDataset() *goqu.SelectDataset { return dialect.From(qb.table()).Select(qb.table().All()) } func (qb *PerformerStore) Create(ctx context.Context, newObject *models.Performer) error { var r performerRow r.fromPerformer(*newObject) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } if newObject.Aliases.Loaded() { if err := performersAliasesTableMgr.insertJoins(ctx, id, newObject.Aliases.List()); err != nil { return err } } if newObject.TagIDs.Loaded() { if err := performersTagsTableMgr.insertJoins(ctx, id, newObject.TagIDs.List()); err != nil { return err } } if newObject.StashIDs.Loaded() { if err := performersStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil { return err } } updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } *newObject = *updated return nil } func (qb *PerformerStore) UpdatePartial(ctx context.Context, id int, partial models.PerformerPartial) (*models.Performer, error) { r := performerRowRecord{ updateRecord{ Record: make(exp.Record), }, } r.fromPartial(partial) if len(r.Record) > 0 { if err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil { return nil, err } } if partial.Aliases != nil { if err := performersAliasesTableMgr.modifyJoins(ctx, id, partial.Aliases.Values, partial.Aliases.Mode); err != nil { return nil, err } } if partial.TagIDs != nil { if err := performersTagsTableMgr.modifyJoins(ctx, id, partial.TagIDs.IDs, partial.TagIDs.Mode); err != nil { return nil, err } } if partial.StashIDs != nil { if err := performersStashIDsTableMgr.modifyJoins(ctx, id, partial.StashIDs.StashIDs, partial.StashIDs.Mode); err != nil { return nil, err } } return qb.find(ctx, id) } func (qb *PerformerStore) Update(ctx context.Context, updatedObject *models.Performer) error { var r performerRow r.fromPerformer(*updatedObject) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err } if updatedObject.Aliases.Loaded() { if err := performersAliasesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Aliases.List()); err != nil { return err } } if updatedObject.TagIDs.Loaded() { if err := performersTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.TagIDs.List()); err != nil { return err } } if updatedObject.StashIDs.Loaded() { if err := performersStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil { return err } } return nil } func (qb *PerformerStore) Destroy(ctx context.Context, id int) error { // must handle image checksums manually if err := qb.destroyImage(ctx, id); err != nil { return err } return qb.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found func (qb *PerformerStore) Find(ctx context.Context, id int) (*models.Performer, error) { ret, err := qb.find(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return ret, err } func (qb *PerformerStore) FindMany(ctx context.Context, ids []int) ([]*models.Performer, error) { tableMgr := performerTableMgr ret := make([]*models.Performer, len(ids)) if err := batchExec(ids, defaultBatchSize, func(batch []int) error { q := goqu.Select("*").From(tableMgr.table).Where(tableMgr.byIDInts(batch...)) unsorted, err := qb.getMany(ctx, q) if err != nil { return err } for _, s := range unsorted { i := intslice.IntIndex(ids, s.ID) ret[i] = s } return nil }); err != nil { return nil, err } for i := range ret { if ret[i] == nil { return nil, fmt.Errorf("performer with id %d not found", ids[i]) } } return ret, nil } // returns nil, sql.ErrNoRows if not found func (qb *PerformerStore) find(ctx context.Context, id int) (*models.Performer, error) { q := qb.selectDataset().Where(qb.tableMgr.byID(id)) ret, err := qb.get(ctx, q) if err != nil { return nil, err } return ret, nil } func (qb *PerformerStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Performer, error) { table := qb.table() q := qb.selectDataset().Where( table.Col(idColumn).Eq( sq, ), ) return qb.getMany(ctx, q) } // returns nil, sql.ErrNoRows if not found func (qb *PerformerStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Performer, error) { ret, err := qb.getMany(ctx, q) if err != nil { return nil, err } if len(ret) == 0 { return nil, sql.ErrNoRows } return ret[0], nil } func (qb *PerformerStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Performer, error) { const single = false var ret []*models.Performer if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { var f performerRow if err := r.StructScan(&f); err != nil { return err } s := f.resolve() ret = append(ret, s) return nil }); err != nil { return nil, err } return ret, nil } func (qb *PerformerStore) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Performer, error) { sq := dialect.From(scenesPerformersJoinTable).Select(scenesPerformersJoinTable.Col(performerIDColumn)).Where( scenesPerformersJoinTable.Col(sceneIDColumn).Eq(sceneID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting performers for scene %d: %w", sceneID, err) } return ret, nil } func (qb *PerformerStore) FindByImageID(ctx context.Context, imageID int) ([]*models.Performer, error) { sq := dialect.From(performersImagesJoinTable).Select(performersImagesJoinTable.Col(performerIDColumn)).Where( performersImagesJoinTable.Col(imageIDColumn).Eq(imageID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting performers for image %d: %w", imageID, err) } return ret, nil } func (qb *PerformerStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Performer, error) { sq := dialect.From(performersGalleriesJoinTable).Select(performersGalleriesJoinTable.Col(performerIDColumn)).Where( performersGalleriesJoinTable.Col(galleryIDColumn).Eq(galleryID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting performers for gallery %d: %w", galleryID, err) } return ret, nil } func (qb *PerformerStore) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Performer, error) { clause := "name " if nocase { clause += "COLLATE NOCASE " } clause += "IN " + getInBinding(len(names)) var args []interface{} for _, name := range names { args = append(args, name) } sq := qb.selectDataset().Prepared(true).Where( goqu.L(clause, args...), ) ret, err := qb.getMany(ctx, sq) if err != nil { return nil, fmt.Errorf("getting performers by names: %w", err) } return ret, nil } func (qb *PerformerStore) CountByTagID(ctx context.Context, tagID int) (int, error) { joinTable := performersTagsJoinTable q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(tagIDColumn).Eq(tagID)) return count(ctx, q) } func (qb *PerformerStore) Count(ctx context.Context) (int, error) { q := dialect.Select(goqu.COUNT("*")).From(qb.table()) return count(ctx, q) } func (qb *PerformerStore) All(ctx context.Context) ([]*models.Performer, error) { table := qb.table() return qb.getMany(ctx, qb.selectDataset().Order(table.Col("name").Asc())) } func (qb *PerformerStore) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Performer, error) { // TODO - Query needs to be changed to support queries of this type, and // this method should be removed table := qb.table() sq := dialect.From(table).Select(table.Col(idColumn)) // TODO - disabled alias matching until we get finer control over it // .LeftJoin( // performersAliasesJoinTable, // goqu.On(performersAliasesJoinTable.Col(performerIDColumn).Eq(table.Col(idColumn))), // ) var whereClauses []exp.Expression for _, w := range words { whereClauses = append(whereClauses, table.Col("name").Like(w+"%")) // TODO - see above // whereClauses = append(whereClauses, performersAliasesJoinTable.Col("alias").Like(w+"%")) } sq = sq.Where( goqu.Or(whereClauses...), table.Col("ignore_auto_tag").Eq(0), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting performers for autotag: %w", err) } return ret, nil } func (qb *PerformerStore) validateFilter(filter *models.PerformerFilterType) error { const and = "AND" const or = "OR" const not = "NOT" if filter.And != nil { if filter.Or != nil { return illegalFilterCombination(and, or) } if filter.Not != nil { return illegalFilterCombination(and, not) } return qb.validateFilter(filter.And) } if filter.Or != nil { if filter.Not != nil { return illegalFilterCombination(or, not) } return qb.validateFilter(filter.Or) } if filter.Not != nil { return qb.validateFilter(filter.Not) } // if legacy height filter used, ensure only supported modifiers are used if filter.Height != nil { // treat as an int filter intCrit := &models.IntCriterionInput{ Modifier: filter.Height.Modifier, } if !intCrit.ValidModifier() { return fmt.Errorf("invalid height modifier: %s", filter.Height.Modifier) } // ensure value is a valid number if _, err := strconv.Atoi(filter.Height.Value); err != nil { return fmt.Errorf("invalid height value: %s", filter.Height.Value) } } return nil } func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.PerformerFilterType) *filterBuilder { query := &filterBuilder{} if filter.And != nil { query.and(qb.makeFilter(ctx, filter.And)) } if filter.Or != nil { query.or(qb.makeFilter(ctx, filter.Or)) } if filter.Not != nil { query.not(qb.makeFilter(ctx, filter.Not)) } const tableName = performerTable query.handleCriterion(ctx, stringCriterionHandler(filter.Name, tableName+".name")) query.handleCriterion(ctx, stringCriterionHandler(filter.Disambiguation, tableName+".disambiguation")) query.handleCriterion(ctx, stringCriterionHandler(filter.Details, tableName+".details")) query.handleCriterion(ctx, boolCriterionHandler(filter.FilterFavorites, tableName+".favorite", nil)) query.handleCriterion(ctx, boolCriterionHandler(filter.IgnoreAutoTag, tableName+".ignore_auto_tag", nil)) query.handleCriterion(ctx, yearFilterCriterionHandler(filter.BirthYear, tableName+".birthdate")) query.handleCriterion(ctx, yearFilterCriterionHandler(filter.DeathYear, tableName+".death_date")) query.handleCriterion(ctx, performerAgeFilterCriterionHandler(filter.Age)) query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if gender := filter.Gender; gender != nil { f.addWhere(tableName+".gender = ?", gender.Value.String()) } })) query.handleCriterion(ctx, performerIsMissingCriterionHandler(qb, filter.IsMissing)) query.handleCriterion(ctx, stringCriterionHandler(filter.Ethnicity, tableName+".ethnicity")) query.handleCriterion(ctx, stringCriterionHandler(filter.Country, tableName+".country")) query.handleCriterion(ctx, stringCriterionHandler(filter.EyeColor, tableName+".eye_color")) // special handler for legacy height filter heightCmCrit := filter.HeightCm if heightCmCrit == nil && filter.Height != nil { heightCm, _ := strconv.Atoi(filter.Height.Value) // already validated heightCmCrit = &models.IntCriterionInput{ Value: heightCm, Modifier: filter.Height.Modifier, } } query.handleCriterion(ctx, intCriterionHandler(heightCmCrit, tableName+".height", nil)) 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")) query.handleCriterion(ctx, intCriterionHandler(filter.Rating100, tableName+".rating", nil)) // legacy rating handler query.handleCriterion(ctx, rating5CriterionHandler(filter.Rating, tableName+".rating", nil)) query.handleCriterion(ctx, stringCriterionHandler(filter.HairColor, tableName+".hair_color")) query.handleCriterion(ctx, stringCriterionHandler(filter.URL, tableName+".url")) query.handleCriterion(ctx, intCriterionHandler(filter.Weight, tableName+".weight", nil)) query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if filter.StashID != nil { qb.stashIDRepository().join(f, "performer_stash_ids", "performers.id") stringCriterionHandler(filter.StashID, "performer_stash_ids.stash_id")(ctx, f) } })) query.handleCriterion(ctx, &stashIDCriterionHandler{ c: filter.StashIDEndpoint, stashIDRepository: qb.stashIDRepository(), stashIDTableAs: "performer_stash_ids", parentIDCol: "performers.id", }) query.handleCriterion(ctx, performerAliasCriterionHandler(qb, filter.Aliases)) query.handleCriterion(ctx, performerTagsCriterionHandler(qb, filter.Tags)) query.handleCriterion(ctx, performerStudiosCriterionHandler(qb, filter.Studios)) query.handleCriterion(ctx, performerAppearsWithCriterionHandler(qb, filter.Performers)) query.handleCriterion(ctx, performerTagCountCriterionHandler(qb, filter.TagCount)) query.handleCriterion(ctx, performerSceneCountCriterionHandler(qb, filter.SceneCount)) query.handleCriterion(ctx, performerImageCountCriterionHandler(qb, filter.ImageCount)) query.handleCriterion(ctx, performerGalleryCountCriterionHandler(qb, filter.GalleryCount)) query.handleCriterion(ctx, performerOCounterCriterionHandler(qb, filter.OCounter)) query.handleCriterion(ctx, dateCriterionHandler(filter.Birthdate, tableName+".birthdate")) query.handleCriterion(ctx, dateCriterionHandler(filter.DeathDate, tableName+".death_date")) query.handleCriterion(ctx, timestampCriterionHandler(filter.CreatedAt, tableName+".created_at")) query.handleCriterion(ctx, timestampCriterionHandler(filter.UpdatedAt, tableName+".updated_at")) return query } func (qb *PerformerStore) makeQuery(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if performerFilter == nil { performerFilter = &models.PerformerFilterType{} } if findFilter == nil { findFilter = &models.FindFilterType{} } query := qb.newQuery() distinctIDs(&query, performerTable) if q := findFilter.Q; q != nil && *q != "" { query.join(performersAliasesTable, "", "performer_aliases.performer_id = performers.id") searchColumns := []string{"performers.name", "performer_aliases.alias"} query.parseQueryString(searchColumns, *q) } if err := qb.validateFilter(performerFilter); err != nil { return nil, err } filter := qb.makeFilter(ctx, performerFilter) if err := query.addFilter(filter); err != nil { return nil, err } query.sortAndPagination = qb.getPerformerSort(findFilter) + getPagination(findFilter) return &query, nil } func (qb *PerformerStore) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) { query, err := qb.makeQuery(ctx, performerFilter, findFilter) if err != nil { return nil, 0, err } idsResult, countResult, err := query.executeFind(ctx) if err != nil { return nil, 0, err } performers, err := qb.FindMany(ctx, idsResult) if err != nil { return nil, 0, err } return performers, countResult, nil } func (qb *PerformerStore) QueryCount(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) (int, error) { query, err := qb.makeQuery(ctx, performerFilter, findFilter) if err != nil { return 0, err } return query.executeCount(ctx) } func performerIsMissingCriterionHandler(qb *PerformerStore, isMissing *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { switch *isMissing { case "scenes": // Deprecated: use `scene_count == 0` filter instead f.addLeftJoin(performersScenesTable, "scenes_join", "scenes_join.performer_id = performers.id") f.addWhere("scenes_join.scene_id IS NULL") case "image": f.addWhere("performers.image_blob IS NULL") case "stash_id": performersStashIDsTableMgr.join(f, "performer_stash_ids", "performers.id") f.addWhere("performer_stash_ids.performer_id IS NULL") default: f.addWhere("(performers." + *isMissing + " IS NULL OR TRIM(performers." + *isMissing + ") = '')") } } } } func yearFilterCriterionHandler(year *models.IntCriterionInput, col string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if year != nil && year.Modifier.IsValid() { clause, args := getIntCriterionWhereClause("cast(strftime('%Y', "+col+") as int)", *year) f.addWhere(clause, args...) } } } func performerAgeFilterCriterionHandler(age *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if age != nil && age.Modifier.IsValid() { clause, args := getIntCriterionWhereClause( "cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int)", *age, ) f.addWhere(clause, args...) } } } func performerAliasCriterionHandler(qb *PerformerStore, alias *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ joinTable: performersAliasesTable, stringColumn: performerAliasColumn, addJoinTable: func(f *filterBuilder) { performersAliasesTableMgr.join(f, "", "performers.id") }, } return h.handler(alias) } func performerTagsCriterionHandler(qb *PerformerStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { h := joinedHierarchicalMultiCriterionHandlerBuilder{ tx: qb.tx, primaryTable: performerTable, foreignTable: tagTable, foreignFK: "tag_id", relationsTable: "tags_relations", joinAs: "image_tag", joinTable: performersTagsTable, primaryFK: performerIDColumn, } return h.handler(tags) } func performerTagCountCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: performerTable, joinTable: performersTagsTable, primaryFK: performerIDColumn, } return h.handler(count) } func performerSceneCountCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: performerTable, joinTable: performersScenesTable, primaryFK: performerIDColumn, } return h.handler(count) } func performerImageCountCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: performerTable, joinTable: performersImagesTable, primaryFK: performerIDColumn, } return h.handler(count) } func performerGalleryCountCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: performerTable, joinTable: performersGalleriesTable, primaryFK: performerIDColumn, } return h.handler(count) } func performerOCounterCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { h := joinedMultiSumCriterionHandlerBuilder{ primaryTable: performerTable, foreignTable1: sceneTable, joinTable1: performersScenesTable, foreignTable2: imageTable, joinTable2: performersImagesTable, primaryFK: performerIDColumn, foreignFK1: sceneIDColumn, foreignFK2: imageIDColumn, sum: "o_counter", } return h.handler(count) } func performerStudiosCriterionHandler(qb *PerformerStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if studios != nil { formatMaps := []utils.StrFormatMap{ { "primaryTable": sceneTable, "joinTable": performersScenesTable, "primaryFK": sceneIDColumn, }, { "primaryTable": imageTable, "joinTable": performersImagesTable, "primaryFK": imageIDColumn, }, { "primaryTable": galleryTable, "joinTable": performersGalleriesTable, "primaryFK": galleryIDColumn, }, } 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.addLeftJoin(c["joinTable"].(string), "", fmt.Sprintf("%s.performer_id = performers.id", c["joinTable"])) f.addLeftJoin(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, err := getHierarchicalValues(ctx, qb.tx, studios.Value, studioTable, "", "parent_id", "child_id", studios.Depth) if err != nil { f.setError(err) return } f.addWith("studio(root_id, item_id) AS (" + valuesClause + ")") templStr := `SELECT performer_id FROM {primaryTable} INNER JOIN {joinTable} ON {primaryTable}.id = {joinTable}.{primaryFK} INNER JOIN studio ON {primaryTable}.studio_id = studio.item_id` var unions []string for _, c := range formatMaps { unions = append(unions, utils.StrFormat(templStr, c)) } f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerStudioTable, strings.Join(unions, " UNION "))) f.addLeftJoin(derivedPerformerStudioTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerStudioTable)) f.addWhere(fmt.Sprintf("%s.performer_id IS %s NULL", derivedPerformerStudioTable, clauseCondition)) } } } func performerAppearsWithCriterionHandler(qb *PerformerStore, performers *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performers != nil { formatMaps := []utils.StrFormatMap{ { "primaryTable": performersScenesTable, "joinTable": performersScenesTable, "primaryFK": sceneIDColumn, }, { "primaryTable": performersImagesTable, "joinTable": performersImagesTable, "primaryFK": imageIDColumn, }, { "primaryTable": performersGalleriesTable, "joinTable": performersGalleriesTable, "primaryFK": galleryIDColumn, }, } if len(performers.Value) == '0' { return } const derivedPerformerPerformersTable = "performer_performers" valuesClause := strings.Join(performers.Value, "),(") f.addWith("performer(id) AS (VALUES(" + valuesClause + "))") templStr := `SELECT {primaryTable}2.performer_id FROM {primaryTable} INNER JOIN {primaryTable} AS {primaryTable}2 ON {primaryTable}.{primaryFK} = {primaryTable}2.{primaryFK} INNER JOIN performer ON {primaryTable}.performer_id = performer.id WHERE {primaryTable}2.performer_id != performer.id` if performers.Modifier == models.CriterionModifierIncludesAll && len(performers.Value) > 1 { templStr += ` GROUP BY {primaryTable}2.performer_id HAVING(count(distinct {primaryTable}.performer_id) IS ` + strconv.Itoa(len(performers.Value)) + `)` } var unions []string for _, c := range formatMaps { unions = append(unions, utils.StrFormat(templStr, c)) } f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerPerformersTable, strings.Join(unions, " UNION "))) f.addInnerJoin(derivedPerformerPerformersTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerPerformersTable)) } } } func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) string { var sort string var direction string if findFilter == nil { sort = "name" direction = "ASC" } else { sort = findFilter.GetSort("name") direction = findFilter.GetDirection() } sortQuery := "" switch sort { case "tag_count": sortQuery += getCountSort(performerTable, performersTagsTable, performerIDColumn, direction) case "scenes_count": sortQuery += getCountSort(performerTable, performersScenesTable, performerIDColumn, direction) case "images_count": sortQuery += getCountSort(performerTable, performersImagesTable, performerIDColumn, direction) case "galleries_count": sortQuery += getCountSort(performerTable, performersGalleriesTable, performerIDColumn, direction) default: sortQuery += getSort(sort, direction, "performers") } if sort == "o_counter" { return getMultiSumSort("o_counter", performerTable, sceneTable, performersScenesTable, imageTable, performersImagesTable, performerIDColumn, sceneIDColumn, imageIDColumn, direction) } // Whatever the sorting, always use name/id as a final sort sortQuery += ", COALESCE(performers.name, performers.id) COLLATE NATURAL_CI ASC" return sortQuery } func (qb *PerformerStore) tagsRepository() *joinRepository { return &joinRepository{ repository: repository{ tx: qb.tx, tableName: performersTagsTable, idColumn: performerIDColumn, }, fkColumn: tagIDColumn, foreignTable: tagTable, orderBy: "tags.name ASC", } } func (qb *PerformerStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { return qb.tagsRepository().getIDs(ctx, id) } func (qb *PerformerStore) GetImage(ctx context.Context, performerID int) ([]byte, error) { return qb.blobJoinQueryBuilder.GetImage(ctx, performerID, performerImageBlobColumn) } func (qb *PerformerStore) HasImage(ctx context.Context, performerID int) (bool, error) { return qb.blobJoinQueryBuilder.HasImage(ctx, performerID, performerImageBlobColumn) } func (qb *PerformerStore) UpdateImage(ctx context.Context, performerID int, image []byte) error { return qb.blobJoinQueryBuilder.UpdateImage(ctx, performerID, performerImageBlobColumn, image) } func (qb *PerformerStore) destroyImage(ctx context.Context, performerID int) error { return qb.blobJoinQueryBuilder.DestroyImage(ctx, performerID, performerImageBlobColumn) } func (qb *PerformerStore) stashIDRepository() *stashIDRepository { return &stashIDRepository{ repository{ tx: qb.tx, tableName: "performer_stash_ids", idColumn: performerIDColumn, }, } } func (qb *PerformerStore) GetAliases(ctx context.Context, performerID int) ([]string, error) { return performersAliasesTableMgr.get(ctx, performerID) } func (qb *PerformerStore) GetStashIDs(ctx context.Context, performerID int) ([]models.StashID, error) { return performersStashIDsTableMgr.get(ctx, performerID) } func (qb *PerformerStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Performer, error) { sq := dialect.From(performersStashIDsJoinTable).Select(performersStashIDsJoinTable.Col(performerIDColumn)).Where( performersStashIDsJoinTable.Col("stash_id").Eq(stashID.StashID), performersStashIDsJoinTable.Col("endpoint").Eq(stashID.Endpoint), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting performers for stash ID %s: %w", stashID.StashID, err) } return ret, nil } func (qb *PerformerStore) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Performer, error) { table := qb.table() sq := dialect.From(table).LeftJoin( performersStashIDsJoinTable, goqu.On(table.Col(idColumn).Eq(performersStashIDsJoinTable.Col(performerIDColumn))), ).Select(table.Col(idColumn)) if hasStashID { sq = sq.Where( performersStashIDsJoinTable.Col("stash_id").IsNotNull(), performersStashIDsJoinTable.Col("endpoint").Eq(stashboxEndpoint), ) } else { sq = sq.Where( performersStashIDsJoinTable.Col("stash_id").IsNull(), ) } ret, err := qb.findBySubquery(ctx, sq) if err != nil { return nil, fmt.Errorf("getting performers for stash-box endpoint %s: %w", stashboxEndpoint, err) } return ret, nil }