mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Add sqlite filter builder. Add AND, OR, NOT filters to scene filter (#1115)
* Add resolution enum extension * Add filter builder * Use filterBuilder for scene query * Optimise joins * Add binary operators to scene query * Use Query for auto-tag
This commit is contained in:
@@ -75,6 +75,10 @@ input SceneMarkerFilterType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input SceneFilterType {
|
input SceneFilterType {
|
||||||
|
AND: SceneFilterType
|
||||||
|
OR: SceneFilterType
|
||||||
|
NOT: SceneFilterType
|
||||||
|
|
||||||
"""Filter by path"""
|
"""Filter by path"""
|
||||||
path: StringCriterionInput
|
path: StringCriterionInput
|
||||||
"""Filter by rating"""
|
"""Filter by rating"""
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -38,13 +39,56 @@ func (t *AutoTagTask) getQueryRegex(name string) string {
|
|||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *AutoTagTask) getQueryFilter(regex string) *models.SceneFilterType {
|
||||||
|
organized := false
|
||||||
|
ret := &models.SceneFilterType{
|
||||||
|
Path: &models.StringCriterionInput{
|
||||||
|
Modifier: models.CriterionModifierMatchesRegex,
|
||||||
|
Value: "(?i)" + regex,
|
||||||
|
},
|
||||||
|
Organized: &organized,
|
||||||
|
}
|
||||||
|
|
||||||
|
sep := string(filepath.Separator)
|
||||||
|
|
||||||
|
var or *models.SceneFilterType
|
||||||
|
for _, p := range t.paths {
|
||||||
|
newOr := &models.SceneFilterType{}
|
||||||
|
if or == nil {
|
||||||
|
ret.And = newOr
|
||||||
|
} else {
|
||||||
|
or.Or = newOr
|
||||||
|
}
|
||||||
|
|
||||||
|
or = newOr
|
||||||
|
|
||||||
|
if !strings.HasSuffix(p, sep) {
|
||||||
|
p = p + sep
|
||||||
|
}
|
||||||
|
|
||||||
|
or.Path = &models.StringCriterionInput{
|
||||||
|
Modifier: models.CriterionModifierEquals,
|
||||||
|
Value: p + "%",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AutoTagTask) getFindFilter() *models.FindFilterType {
|
||||||
|
perPage := 0
|
||||||
|
return &models.FindFilterType{
|
||||||
|
PerPage: &perPage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (t *AutoTagPerformerTask) autoTagPerformer() {
|
func (t *AutoTagPerformerTask) autoTagPerformer() {
|
||||||
regex := t.getQueryRegex(t.performer.Name.String)
|
regex := t.getQueryRegex(t.performer.Name.String)
|
||||||
|
|
||||||
if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
|
if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
|
||||||
qb := r.Scene()
|
qb := r.Scene()
|
||||||
|
|
||||||
scenes, err := qb.QueryForAutoTag(regex, t.paths)
|
scenes, _, err := qb.Query(t.getQueryFilter(regex), t.getFindFilter())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Error querying scenes with regex '%s': %s", regex, err.Error())
|
return fmt.Errorf("Error querying scenes with regex '%s': %s", regex, err.Error())
|
||||||
@@ -84,7 +128,7 @@ func (t *AutoTagStudioTask) autoTagStudio() {
|
|||||||
|
|
||||||
if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
|
if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
|
||||||
qb := r.Scene()
|
qb := r.Scene()
|
||||||
scenes, err := qb.QueryForAutoTag(regex, t.paths)
|
scenes, _, err := qb.Query(t.getQueryFilter(regex), t.getFindFilter())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Error querying scenes with regex '%s': %s", regex, err.Error())
|
return fmt.Errorf("Error querying scenes with regex '%s': %s", regex, err.Error())
|
||||||
@@ -133,7 +177,7 @@ func (t *AutoTagTagTask) autoTagTag() {
|
|||||||
|
|
||||||
if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
|
if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
|
||||||
qb := r.Scene()
|
qb := r.Scene()
|
||||||
scenes, err := qb.QueryForAutoTag(regex, t.paths)
|
scenes, _, err := qb.Query(t.getQueryFilter(regex), t.getFindFilter())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Error querying scenes with regex '%s': %s", regex, err.Error())
|
return fmt.Errorf("Error querying scenes with regex '%s': %s", regex, err.Error())
|
||||||
|
|||||||
65
pkg/models/extension_resolution.go
Normal file
65
pkg/models/extension_resolution.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
var resolutionMax = []int{
|
||||||
|
240,
|
||||||
|
360,
|
||||||
|
480,
|
||||||
|
540,
|
||||||
|
720,
|
||||||
|
1080,
|
||||||
|
1440,
|
||||||
|
1920,
|
||||||
|
2160,
|
||||||
|
2880,
|
||||||
|
3384,
|
||||||
|
4320,
|
||||||
|
0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxResolution returns the maximum width or height that media must be
|
||||||
|
// to qualify as this resolution. A return value of 0 means that there is no
|
||||||
|
// maximum.
|
||||||
|
func (r *ResolutionEnum) GetMaxResolution() int {
|
||||||
|
if !r.IsValid() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanity check - length of arrays must be the same
|
||||||
|
if len(resolutionMax) != len(AllResolutionEnum) {
|
||||||
|
panic("resolutionMax array length != AllResolutionEnum array length")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, rr := range AllResolutionEnum {
|
||||||
|
if rr == *r {
|
||||||
|
return resolutionMax[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMinResolution returns the minimum width or height that media must be
|
||||||
|
// to qualify as this resolution.
|
||||||
|
func (r *ResolutionEnum) GetMinResolution() int {
|
||||||
|
if !r.IsValid() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanity check - length of arrays must be the same
|
||||||
|
if len(resolutionMax) != len(AllResolutionEnum) {
|
||||||
|
panic("resolutionMax array length != AllResolutionEnum array length")
|
||||||
|
}
|
||||||
|
|
||||||
|
// use the previous resolution max as this resolution min
|
||||||
|
for i, rr := range AllResolutionEnum {
|
||||||
|
if rr == *r {
|
||||||
|
if i > 0 {
|
||||||
|
return resolutionMax[i-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -21,7 +21,6 @@ type SceneReader interface {
|
|||||||
CountMissingOSHash() (int, error)
|
CountMissingOSHash() (int, error)
|
||||||
Wall(q *string) ([]*Scene, error)
|
Wall(q *string) ([]*Scene, error)
|
||||||
All() ([]*Scene, error)
|
All() ([]*Scene, error)
|
||||||
QueryForAutoTag(regex string, pathPrefixes []string) ([]*Scene, error)
|
|
||||||
Query(sceneFilter *SceneFilterType, findFilter *FindFilterType) ([]*Scene, int, error)
|
Query(sceneFilter *SceneFilterType, findFilter *FindFilterType) ([]*Scene, int, error)
|
||||||
GetCover(sceneID int) ([]byte, error)
|
GetCover(sceneID int) ([]byte, error)
|
||||||
GetMovies(sceneID int) ([]MoviesScenes, error)
|
GetMovies(sceneID int) ([]MoviesScenes, error)
|
||||||
|
|||||||
400
pkg/sqlite/filter.go
Normal file
400
pkg/sqlite/filter.go
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sqlClause struct {
|
||||||
|
sql string
|
||||||
|
args []interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeClause(sql string, args ...interface{}) sqlClause {
|
||||||
|
return sqlClause{
|
||||||
|
sql: sql,
|
||||||
|
args: args,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type criterionHandler interface {
|
||||||
|
handle(f *filterBuilder)
|
||||||
|
}
|
||||||
|
|
||||||
|
type criterionHandlerFunc func(f *filterBuilder)
|
||||||
|
|
||||||
|
type join struct {
|
||||||
|
table string
|
||||||
|
as string
|
||||||
|
onClause string
|
||||||
|
}
|
||||||
|
|
||||||
|
// equals returns true if the other join alias/table is equal to this one
|
||||||
|
func (j join) equals(o join) bool {
|
||||||
|
return j.alias() == o.alias()
|
||||||
|
}
|
||||||
|
|
||||||
|
// alias returns the as string, or the table if as is empty
|
||||||
|
func (j join) alias() string {
|
||||||
|
if j.as == "" {
|
||||||
|
return j.table
|
||||||
|
}
|
||||||
|
|
||||||
|
return j.as
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j join) toSQL() string {
|
||||||
|
asStr := ""
|
||||||
|
if j.as != "" && j.as != j.table {
|
||||||
|
asStr = " AS " + j.as
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("LEFT JOIN %s%s ON %s", j.table, asStr, j.onClause)
|
||||||
|
}
|
||||||
|
|
||||||
|
type joins []join
|
||||||
|
|
||||||
|
func (j *joins) add(newJoins ...join) {
|
||||||
|
// only add if not already joined
|
||||||
|
for _, newJoin := range newJoins {
|
||||||
|
for _, jj := range *j {
|
||||||
|
if jj.equals(newJoin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*j = append(*j, newJoin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *joins) toSQL() string {
|
||||||
|
var ret []string
|
||||||
|
for _, jj := range *j {
|
||||||
|
ret = append(ret, jj.toSQL())
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(ret, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
type filterBuilder struct {
|
||||||
|
subFilter *filterBuilder
|
||||||
|
subFilterOp string
|
||||||
|
|
||||||
|
joins joins
|
||||||
|
whereClauses []sqlClause
|
||||||
|
havingClauses []sqlClause
|
||||||
|
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
var errSubFilterAlreadySet error = errors.New(`sub-filter already set`)
|
||||||
|
|
||||||
|
// sub-filter operator values
|
||||||
|
var (
|
||||||
|
andOp = "AND"
|
||||||
|
orOp = "OR"
|
||||||
|
notOp = "AND NOT"
|
||||||
|
)
|
||||||
|
|
||||||
|
// and sets the sub-filter that will be ANDed with this one.
|
||||||
|
// Sets the error state if sub-filter is already set.
|
||||||
|
func (f *filterBuilder) and(a *filterBuilder) {
|
||||||
|
if f.subFilter != nil {
|
||||||
|
f.setError(errSubFilterAlreadySet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f.subFilter = a
|
||||||
|
f.subFilterOp = andOp
|
||||||
|
}
|
||||||
|
|
||||||
|
// or sets the sub-filter that will be ORed with this one.
|
||||||
|
// Sets the error state if a sub-filter is already set.
|
||||||
|
func (f *filterBuilder) or(o *filterBuilder) {
|
||||||
|
if f.subFilter != nil {
|
||||||
|
f.setError(errSubFilterAlreadySet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f.subFilter = o
|
||||||
|
f.subFilterOp = orOp
|
||||||
|
}
|
||||||
|
|
||||||
|
// not sets the sub-filter that will be AND NOTed with this one.
|
||||||
|
// Sets the error state if a sub-filter is already set.
|
||||||
|
func (f *filterBuilder) not(n *filterBuilder) {
|
||||||
|
if f.subFilter != nil {
|
||||||
|
f.setError(errSubFilterAlreadySet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f.subFilter = n
|
||||||
|
f.subFilterOp = notOp
|
||||||
|
}
|
||||||
|
|
||||||
|
// addJoin adds a join to the filter. The join is expressed in SQL as:
|
||||||
|
// LEFT JOIN <table> [AS <as>] ON <onClause>
|
||||||
|
// The AS is omitted if as is empty.
|
||||||
|
// This method does not add a join if it its alias/table name is already
|
||||||
|
// present in another existing join.
|
||||||
|
func (f *filterBuilder) addJoin(table, as, onClause string) {
|
||||||
|
newJoin := join{
|
||||||
|
table: table,
|
||||||
|
as: as,
|
||||||
|
onClause: onClause,
|
||||||
|
}
|
||||||
|
|
||||||
|
f.joins.add(newJoin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addWhere adds a where clause and arguments to the filter. Where clauses
|
||||||
|
// are ANDed together. Does not add anything if the provided string is empty.
|
||||||
|
func (f *filterBuilder) addWhere(sql string, args ...interface{}) {
|
||||||
|
if sql == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.whereClauses = append(f.whereClauses, makeClause(sql, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// addHaving adds a where clause and arguments to the filter. Having clauses
|
||||||
|
// are ANDed together. Does not add anything if the provided string is empty.
|
||||||
|
func (f *filterBuilder) addHaving(sql string, args ...interface{}) {
|
||||||
|
if sql == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.havingClauses = append(f.havingClauses, makeClause(sql, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *filterBuilder) getSubFilterClause(clause, subFilterClause string) string {
|
||||||
|
ret := clause
|
||||||
|
|
||||||
|
if subFilterClause != "" {
|
||||||
|
var op string
|
||||||
|
if len(ret) > 0 {
|
||||||
|
op = " " + f.subFilterOp + " "
|
||||||
|
} else {
|
||||||
|
if f.subFilterOp == notOp {
|
||||||
|
op = "NOT "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ret += op + subFilterClause
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateWhereClauses generates the SQL where clause for this filter.
|
||||||
|
// All where clauses within the filter are ANDed together. This is combined
|
||||||
|
// with the sub-filter, which will use the applicable operator (AND/OR/AND NOT).
|
||||||
|
func (f *filterBuilder) generateWhereClauses() (clause string, args []interface{}) {
|
||||||
|
clause, args = f.andClauses(f.whereClauses)
|
||||||
|
|
||||||
|
if f.subFilter != nil {
|
||||||
|
c, a := f.subFilter.generateWhereClauses()
|
||||||
|
if c != "" {
|
||||||
|
clause = f.getSubFilterClause(clause, c)
|
||||||
|
if len(a) > 0 {
|
||||||
|
args = append(args, a...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateHavingClauses generates the SQL having clause for this filter.
|
||||||
|
// All having clauses within the filter are ANDed together. This is combined
|
||||||
|
// with the sub-filter, which will use the applicable operator (AND/OR/AND NOT).
|
||||||
|
func (f *filterBuilder) generateHavingClauses() (string, []interface{}) {
|
||||||
|
clause, args := f.andClauses(f.havingClauses)
|
||||||
|
|
||||||
|
if f.subFilter != nil {
|
||||||
|
c, a := f.subFilter.generateHavingClauses()
|
||||||
|
if c != "" {
|
||||||
|
clause += " " + f.subFilterOp + " " + c
|
||||||
|
if len(a) > 0 {
|
||||||
|
args = append(args, a...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clause, args
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAllJoins returns all of the joins in this filter and any sub-filter(s).
|
||||||
|
// Redundant joins will not be duplicated in the return value.
|
||||||
|
func (f *filterBuilder) getAllJoins() joins {
|
||||||
|
var ret joins
|
||||||
|
ret.add(f.joins...)
|
||||||
|
if f.subFilter != nil {
|
||||||
|
subJoins := f.subFilter.getAllJoins()
|
||||||
|
if len(subJoins) > 0 {
|
||||||
|
ret.add(subJoins...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// getError returns the error state on this filter, or on any sub-filter(s) if
|
||||||
|
// the error state is nil.
|
||||||
|
func (f *filterBuilder) getError() error {
|
||||||
|
if f.err != nil {
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.subFilter != nil {
|
||||||
|
return f.subFilter.getError()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCriterion calls the handle function on the provided criterionHandler,
|
||||||
|
// providing itself.
|
||||||
|
func (f *filterBuilder) handleCriterion(handler criterionHandler) {
|
||||||
|
f.handleCriterionFunc(func(h *filterBuilder) {
|
||||||
|
handler.handle(h)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCriterionFunc calls the provided criterion handler function providing
|
||||||
|
// itself.
|
||||||
|
func (f *filterBuilder) handleCriterionFunc(handler criterionHandlerFunc) {
|
||||||
|
handler(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *filterBuilder) setError(e error) {
|
||||||
|
if f.err == nil {
|
||||||
|
f.err = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *filterBuilder) andClauses(input []sqlClause) (string, []interface{}) {
|
||||||
|
var clauses []string
|
||||||
|
var args []interface{}
|
||||||
|
for _, w := range input {
|
||||||
|
clauses = append(clauses, w.sql)
|
||||||
|
args = append(args, w.args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(clauses) > 0 {
|
||||||
|
c := "(" + strings.Join(clauses, " AND ") + ")"
|
||||||
|
return c, args
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringCriterionHandler(c *models.StringCriterionInput, column string) criterionHandlerFunc {
|
||||||
|
return func(f *filterBuilder) {
|
||||||
|
if c != nil {
|
||||||
|
if modifier := c.Modifier; c.Modifier.IsValid() {
|
||||||
|
switch modifier {
|
||||||
|
case models.CriterionModifierIncludes:
|
||||||
|
clause, thisArgs := getSearchBinding([]string{column}, c.Value, false)
|
||||||
|
f.addWhere(clause, thisArgs...)
|
||||||
|
case models.CriterionModifierExcludes:
|
||||||
|
clause, thisArgs := getSearchBinding([]string{column}, c.Value, true)
|
||||||
|
f.addWhere(clause, thisArgs...)
|
||||||
|
case models.CriterionModifierEquals:
|
||||||
|
f.addWhere(column+" LIKE ?", c.Value)
|
||||||
|
case models.CriterionModifierNotEquals:
|
||||||
|
f.addWhere(column+" NOT LIKE ?", c.Value)
|
||||||
|
case models.CriterionModifierMatchesRegex:
|
||||||
|
if _, err := regexp.Compile(c.Value); err != nil {
|
||||||
|
f.setError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.addWhere(column+" regexp ?", c.Value)
|
||||||
|
case models.CriterionModifierNotMatchesRegex:
|
||||||
|
if _, err := regexp.Compile(c.Value); err != nil {
|
||||||
|
f.setError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.addWhere(column+" NOT regexp ?", c.Value)
|
||||||
|
default:
|
||||||
|
clause, count := getSimpleCriterionClause(modifier, "?")
|
||||||
|
|
||||||
|
if count == 1 {
|
||||||
|
f.addWhere(column+" "+clause, c.Value)
|
||||||
|
} else {
|
||||||
|
f.addWhere(column + " " + clause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intCriterionHandler(c *models.IntCriterionInput, column string) criterionHandlerFunc {
|
||||||
|
return func(f *filterBuilder) {
|
||||||
|
if c != nil {
|
||||||
|
clause, count := getIntCriterionWhereClause(column, *c)
|
||||||
|
|
||||||
|
if count == 1 {
|
||||||
|
f.addWhere(clause, c.Value)
|
||||||
|
} else {
|
||||||
|
f.addWhere(clause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolCriterionHandler(c *bool, column string) criterionHandlerFunc {
|
||||||
|
return func(f *filterBuilder) {
|
||||||
|
if c != nil {
|
||||||
|
var v string
|
||||||
|
if *c {
|
||||||
|
v = "1"
|
||||||
|
} else {
|
||||||
|
v = "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
f.addWhere(column + " = " + v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringLiteralCriterionHandler(v *string, column string) criterionHandlerFunc {
|
||||||
|
return func(f *filterBuilder) {
|
||||||
|
if v != nil {
|
||||||
|
f.addWhere(column+" = ?", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type multiCriterionHandlerBuilder struct {
|
||||||
|
primaryTable string
|
||||||
|
foreignTable string
|
||||||
|
joinTable string
|
||||||
|
primaryFK string
|
||||||
|
foreignFK string
|
||||||
|
|
||||||
|
// function that will be called to perform any necessary joins
|
||||||
|
addJoinsFunc func(f *filterBuilder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *multiCriterionHandlerBuilder) 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 m.addJoinsFunc != nil {
|
||||||
|
m.addJoinsFunc(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
whereClause, havingClause := getMultiCriterionClause(m.primaryTable, m.foreignTable, m.joinTable, m.primaryFK, m.foreignFK, criterion)
|
||||||
|
f.addWhere(whereClause, args...)
|
||||||
|
f.addHaving(havingClause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
603
pkg/sqlite/filter_internal_test.go
Normal file
603
pkg/sqlite/filter_internal_test.go
Normal file
@@ -0,0 +1,603 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilterBuilderAnd(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
f := &filterBuilder{}
|
||||||
|
other := &filterBuilder{}
|
||||||
|
newBuilder := &filterBuilder{}
|
||||||
|
|
||||||
|
// and should set the subFilter
|
||||||
|
f.and(other)
|
||||||
|
assert.Equal(other, f.subFilter)
|
||||||
|
assert.Nil(f.getError())
|
||||||
|
|
||||||
|
// and should set error if and is set
|
||||||
|
f.and(newBuilder)
|
||||||
|
assert.Equal(other, f.subFilter)
|
||||||
|
assert.Equal(errSubFilterAlreadySet, f.getError())
|
||||||
|
|
||||||
|
// and should set error if or is set
|
||||||
|
// and should not set subFilter if or is set
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.or(other)
|
||||||
|
f.and(newBuilder)
|
||||||
|
assert.Equal(other, f.subFilter)
|
||||||
|
assert.Equal(errSubFilterAlreadySet, f.getError())
|
||||||
|
|
||||||
|
// and should set error if not is set
|
||||||
|
// and should not set subFilter if not is set
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.not(other)
|
||||||
|
f.and(newBuilder)
|
||||||
|
assert.Equal(other, f.subFilter)
|
||||||
|
assert.Equal(errSubFilterAlreadySet, f.getError())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterBuilderOr(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
f := &filterBuilder{}
|
||||||
|
other := &filterBuilder{}
|
||||||
|
newBuilder := &filterBuilder{}
|
||||||
|
|
||||||
|
// or should set the orFilter
|
||||||
|
f.or(other)
|
||||||
|
assert.Equal(other, f.subFilter)
|
||||||
|
assert.Nil(f.getError())
|
||||||
|
|
||||||
|
// or should set error if or is set
|
||||||
|
f.or(newBuilder)
|
||||||
|
assert.Equal(newBuilder, f.subFilter)
|
||||||
|
assert.Equal(errSubFilterAlreadySet, f.getError())
|
||||||
|
|
||||||
|
// or should set error if and is set
|
||||||
|
// or should not set subFilter if and is set
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.and(other)
|
||||||
|
f.or(newBuilder)
|
||||||
|
assert.Equal(other, f.subFilter)
|
||||||
|
assert.Equal(errSubFilterAlreadySet, f.getError())
|
||||||
|
|
||||||
|
// or should set error if not is set
|
||||||
|
// or should not set subFilter if not is set
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.not(other)
|
||||||
|
f.or(newBuilder)
|
||||||
|
assert.Equal(other, f.subFilter)
|
||||||
|
assert.Equal(errSubFilterAlreadySet, f.getError())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterBuilderNot(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
f := &filterBuilder{}
|
||||||
|
other := &filterBuilder{}
|
||||||
|
newBuilder := &filterBuilder{}
|
||||||
|
|
||||||
|
// not should set the subFilter
|
||||||
|
f.not(other)
|
||||||
|
// ensure and filter is set
|
||||||
|
assert.Equal(other, f.subFilter)
|
||||||
|
assert.Nil(f.getError())
|
||||||
|
|
||||||
|
// not should set error if not is set
|
||||||
|
f.not(newBuilder)
|
||||||
|
assert.Equal(newBuilder, f.subFilter)
|
||||||
|
assert.Equal(errSubFilterAlreadySet, f.getError())
|
||||||
|
|
||||||
|
// not should set error if and is set
|
||||||
|
// not should not set subFilter if and is set
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.and(other)
|
||||||
|
f.not(newBuilder)
|
||||||
|
assert.Equal(other, f.subFilter)
|
||||||
|
assert.Equal(errSubFilterAlreadySet, f.getError())
|
||||||
|
|
||||||
|
// not should set error if or is set
|
||||||
|
// not should not set subFilter if or is set
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.or(other)
|
||||||
|
f.not(newBuilder)
|
||||||
|
assert.Equal(other, f.subFilter)
|
||||||
|
assert.Equal(errSubFilterAlreadySet, f.getError())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddJoin(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
f := &filterBuilder{}
|
||||||
|
|
||||||
|
const (
|
||||||
|
table1Name = "table1Name"
|
||||||
|
table2Name = "table2Name"
|
||||||
|
|
||||||
|
as1Name = "as1"
|
||||||
|
as2Name = "as2"
|
||||||
|
|
||||||
|
onClause = "onClause1"
|
||||||
|
)
|
||||||
|
|
||||||
|
f.addJoin(table1Name, as1Name, onClause)
|
||||||
|
|
||||||
|
// ensure join is added
|
||||||
|
assert.Len(f.joins, 1)
|
||||||
|
assert.Equal(fmt.Sprintf("LEFT JOIN %s AS %s ON %s", table1Name, as1Name, onClause), f.joins[0].toSQL())
|
||||||
|
|
||||||
|
// ensure join with same as is not added
|
||||||
|
f.addJoin(table2Name, as1Name, onClause)
|
||||||
|
assert.Len(f.joins, 1)
|
||||||
|
|
||||||
|
// ensure same table with different alias can be added
|
||||||
|
f.addJoin(table1Name, as2Name, onClause)
|
||||||
|
assert.Len(f.joins, 2)
|
||||||
|
assert.Equal(fmt.Sprintf("LEFT JOIN %s AS %s ON %s", table1Name, as2Name, onClause), f.joins[1].toSQL())
|
||||||
|
|
||||||
|
// ensure table without alias can be added if tableName != existing alias/tableName
|
||||||
|
f.addJoin(table1Name, "", onClause)
|
||||||
|
assert.Len(f.joins, 3)
|
||||||
|
assert.Equal(fmt.Sprintf("LEFT JOIN %s ON %s", table1Name, onClause), f.joins[2].toSQL())
|
||||||
|
|
||||||
|
// ensure table with alias == table name of a join without alias is not added
|
||||||
|
f.addJoin(table2Name, table1Name, onClause)
|
||||||
|
assert.Len(f.joins, 3)
|
||||||
|
|
||||||
|
// ensure table without alias cannot be added if tableName == existing alias
|
||||||
|
f.addJoin(as2Name, "", onClause)
|
||||||
|
assert.Len(f.joins, 3)
|
||||||
|
|
||||||
|
// ensure AS is not used if same as table name
|
||||||
|
f.addJoin(table2Name, table2Name, onClause)
|
||||||
|
assert.Len(f.joins, 4)
|
||||||
|
assert.Equal(fmt.Sprintf("LEFT JOIN %s ON %s", table2Name, onClause), f.joins[3].toSQL())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddWhere(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
f := &filterBuilder{}
|
||||||
|
|
||||||
|
// ensure empty sql adds nothing
|
||||||
|
f.addWhere("")
|
||||||
|
assert.Len(f.whereClauses, 0)
|
||||||
|
|
||||||
|
const whereClause = "a = b"
|
||||||
|
var args = []interface{}{"1", "2"}
|
||||||
|
|
||||||
|
// ensure addWhere sets where clause and args
|
||||||
|
f.addWhere(whereClause, args...)
|
||||||
|
assert.Len(f.whereClauses, 1)
|
||||||
|
assert.Equal(whereClause, f.whereClauses[0].sql)
|
||||||
|
assert.Equal(args, f.whereClauses[0].args)
|
||||||
|
|
||||||
|
// ensure addWhere without args sets where clause
|
||||||
|
f.addWhere(whereClause)
|
||||||
|
assert.Len(f.whereClauses, 2)
|
||||||
|
assert.Equal(whereClause, f.whereClauses[1].sql)
|
||||||
|
assert.Len(f.whereClauses[1].args, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddHaving(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
f := &filterBuilder{}
|
||||||
|
|
||||||
|
// ensure empty sql adds nothing
|
||||||
|
f.addHaving("")
|
||||||
|
assert.Len(f.havingClauses, 0)
|
||||||
|
|
||||||
|
const havingClause = "a = b"
|
||||||
|
var args = []interface{}{"1", "2"}
|
||||||
|
|
||||||
|
// ensure addWhere sets where clause and args
|
||||||
|
f.addHaving(havingClause, args...)
|
||||||
|
assert.Len(f.havingClauses, 1)
|
||||||
|
assert.Equal(havingClause, f.havingClauses[0].sql)
|
||||||
|
assert.Equal(args, f.havingClauses[0].args)
|
||||||
|
|
||||||
|
// ensure addWhere without args sets where clause
|
||||||
|
f.addHaving(havingClause)
|
||||||
|
assert.Len(f.havingClauses, 2)
|
||||||
|
assert.Equal(havingClause, f.havingClauses[1].sql)
|
||||||
|
assert.Len(f.havingClauses[1].args, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateWhereClauses(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
f := &filterBuilder{}
|
||||||
|
|
||||||
|
const clause1 = "a = 1"
|
||||||
|
const clause2 = "b = 2"
|
||||||
|
const clause3 = "c = 3"
|
||||||
|
|
||||||
|
const arg1 = "1"
|
||||||
|
const arg2 = "2"
|
||||||
|
const arg3 = "3"
|
||||||
|
|
||||||
|
// ensure single where clause is generated correctly
|
||||||
|
f.addWhere(clause1)
|
||||||
|
r, rArgs := f.generateWhereClauses()
|
||||||
|
assert.Equal("("+clause1+")", r)
|
||||||
|
assert.Len(rArgs, 0)
|
||||||
|
|
||||||
|
// ensure multiple where clauses are surrounded with parenthesis and
|
||||||
|
// ANDed together
|
||||||
|
f.addWhere(clause2, arg1, arg2)
|
||||||
|
r, rArgs = f.generateWhereClauses()
|
||||||
|
assert.Equal("("+clause1+" AND "+clause2+")", r)
|
||||||
|
assert.Len(rArgs, 2)
|
||||||
|
|
||||||
|
// ensure empty subfilter is not added to generated where clause
|
||||||
|
sf := &filterBuilder{}
|
||||||
|
f.and(sf)
|
||||||
|
|
||||||
|
r, rArgs = f.generateWhereClauses()
|
||||||
|
assert.Equal("("+clause1+" AND "+clause2+")", r)
|
||||||
|
assert.Len(rArgs, 2)
|
||||||
|
|
||||||
|
// ensure sub-filter is generated correctly
|
||||||
|
sf.addWhere(clause3, arg3)
|
||||||
|
r, rArgs = f.generateWhereClauses()
|
||||||
|
assert.Equal("("+clause1+" AND "+clause2+") AND ("+clause3+")", r)
|
||||||
|
assert.Len(rArgs, 3)
|
||||||
|
|
||||||
|
// ensure OR sub-filter is generated correctly
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.addWhere(clause1)
|
||||||
|
f.addWhere(clause2, arg1, arg2)
|
||||||
|
f.or(sf)
|
||||||
|
|
||||||
|
r, rArgs = f.generateWhereClauses()
|
||||||
|
assert.Equal("("+clause1+" AND "+clause2+") OR ("+clause3+")", r)
|
||||||
|
assert.Len(rArgs, 3)
|
||||||
|
|
||||||
|
// ensure NOT sub-filter is generated correctly
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.addWhere(clause1)
|
||||||
|
f.addWhere(clause2, arg1, arg2)
|
||||||
|
f.not(sf)
|
||||||
|
|
||||||
|
r, rArgs = f.generateWhereClauses()
|
||||||
|
assert.Equal("("+clause1+" AND "+clause2+") AND NOT ("+clause3+")", r)
|
||||||
|
assert.Len(rArgs, 3)
|
||||||
|
|
||||||
|
// ensure empty filter with ANDed sub-filter does not include AND
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.and(sf)
|
||||||
|
|
||||||
|
r, rArgs = f.generateWhereClauses()
|
||||||
|
assert.Equal("("+clause3+")", r)
|
||||||
|
assert.Len(rArgs, 1)
|
||||||
|
|
||||||
|
// ensure empty filter with ORed sub-filter does not include OR
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.or(sf)
|
||||||
|
|
||||||
|
r, rArgs = f.generateWhereClauses()
|
||||||
|
assert.Equal("("+clause3+")", r)
|
||||||
|
assert.Len(rArgs, 1)
|
||||||
|
|
||||||
|
// ensure empty filter with NOTed sub-filter does not include AND
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.not(sf)
|
||||||
|
|
||||||
|
r, rArgs = f.generateWhereClauses()
|
||||||
|
assert.Equal("NOT ("+clause3+")", r)
|
||||||
|
assert.Len(rArgs, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateHavingClauses(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
f := &filterBuilder{}
|
||||||
|
|
||||||
|
const clause1 = "a = 1"
|
||||||
|
const clause2 = "b = 2"
|
||||||
|
const clause3 = "c = 3"
|
||||||
|
|
||||||
|
const arg1 = "1"
|
||||||
|
const arg2 = "2"
|
||||||
|
const arg3 = "3"
|
||||||
|
|
||||||
|
// ensure single Having clause is generated correctly
|
||||||
|
f.addHaving(clause1)
|
||||||
|
r, rArgs := f.generateHavingClauses()
|
||||||
|
assert.Equal("("+clause1+")", r)
|
||||||
|
assert.Len(rArgs, 0)
|
||||||
|
|
||||||
|
// ensure multiple Having clauses are surrounded with parenthesis and
|
||||||
|
// ANDed together
|
||||||
|
f.addHaving(clause2, arg1, arg2)
|
||||||
|
r, rArgs = f.generateHavingClauses()
|
||||||
|
assert.Equal("("+clause1+" AND "+clause2+")", r)
|
||||||
|
assert.Len(rArgs, 2)
|
||||||
|
|
||||||
|
// ensure empty subfilter is not added to generated Having clause
|
||||||
|
sf := &filterBuilder{}
|
||||||
|
f.and(sf)
|
||||||
|
|
||||||
|
r, rArgs = f.generateHavingClauses()
|
||||||
|
assert.Equal("("+clause1+" AND "+clause2+")", r)
|
||||||
|
assert.Len(rArgs, 2)
|
||||||
|
|
||||||
|
// ensure sub-filter is generated correctly
|
||||||
|
sf.addHaving(clause3, arg3)
|
||||||
|
r, rArgs = f.generateHavingClauses()
|
||||||
|
assert.Equal("("+clause1+" AND "+clause2+") AND ("+clause3+")", r)
|
||||||
|
assert.Len(rArgs, 3)
|
||||||
|
|
||||||
|
// ensure OR sub-filter is generated correctly
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.addHaving(clause1)
|
||||||
|
f.addHaving(clause2, arg1, arg2)
|
||||||
|
f.or(sf)
|
||||||
|
|
||||||
|
r, rArgs = f.generateHavingClauses()
|
||||||
|
assert.Equal("("+clause1+" AND "+clause2+") OR ("+clause3+")", r)
|
||||||
|
assert.Len(rArgs, 3)
|
||||||
|
|
||||||
|
// ensure NOT sub-filter is generated correctly
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.addHaving(clause1)
|
||||||
|
f.addHaving(clause2, arg1, arg2)
|
||||||
|
f.not(sf)
|
||||||
|
|
||||||
|
r, rArgs = f.generateHavingClauses()
|
||||||
|
assert.Equal("("+clause1+" AND "+clause2+") AND NOT ("+clause3+")", r)
|
||||||
|
assert.Len(rArgs, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAllJoins(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
f := &filterBuilder{}
|
||||||
|
|
||||||
|
const (
|
||||||
|
table1Name = "table1Name"
|
||||||
|
table2Name = "table2Name"
|
||||||
|
|
||||||
|
as1Name = "as1"
|
||||||
|
as2Name = "as2"
|
||||||
|
|
||||||
|
onClause = "onClause1"
|
||||||
|
)
|
||||||
|
|
||||||
|
f.addJoin(table1Name, as1Name, onClause)
|
||||||
|
|
||||||
|
// ensure join is returned
|
||||||
|
joins := f.getAllJoins()
|
||||||
|
assert.Len(joins, 1)
|
||||||
|
assert.Equal(fmt.Sprintf("LEFT JOIN %s AS %s ON %s", table1Name, as1Name, onClause), joins[0].toSQL())
|
||||||
|
|
||||||
|
// ensure joins in sub-filter are returned
|
||||||
|
subFilter := &filterBuilder{}
|
||||||
|
f.and(subFilter)
|
||||||
|
subFilter.addJoin(table2Name, as2Name, onClause)
|
||||||
|
|
||||||
|
joins = f.getAllJoins()
|
||||||
|
assert.Len(joins, 2)
|
||||||
|
assert.Equal(fmt.Sprintf("LEFT JOIN %s AS %s ON %s", table2Name, as2Name, onClause), joins[1].toSQL())
|
||||||
|
|
||||||
|
// ensure redundant joins are not returned
|
||||||
|
subFilter.addJoin(as1Name, "", onClause)
|
||||||
|
joins = f.getAllJoins()
|
||||||
|
assert.Len(joins, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetError(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
f := &filterBuilder{}
|
||||||
|
subFilter := &filterBuilder{}
|
||||||
|
|
||||||
|
f.and(subFilter)
|
||||||
|
|
||||||
|
expectedErr := errors.New("test error")
|
||||||
|
expectedErr2 := errors.New("test error2")
|
||||||
|
f.err = expectedErr
|
||||||
|
subFilter.err = expectedErr2
|
||||||
|
|
||||||
|
// ensure getError returns the top-level error state
|
||||||
|
assert.Equal(expectedErr, f.getError())
|
||||||
|
|
||||||
|
// ensure getError returns sub-filter error state if top-level error
|
||||||
|
// is nil
|
||||||
|
f.err = nil
|
||||||
|
assert.Equal(expectedErr2, f.getError())
|
||||||
|
|
||||||
|
// ensure getError returns nil if all error states are nil
|
||||||
|
subFilter.err = nil
|
||||||
|
assert.Nil(f.getError())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringCriterionHandlerIncludes(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
const column = "column"
|
||||||
|
const value1 = "two words"
|
||||||
|
const quotedValue = `"two words"`
|
||||||
|
|
||||||
|
f := &filterBuilder{}
|
||||||
|
f.handleCriterionFunc(stringCriterionHandler(&models.StringCriterionInput{
|
||||||
|
Modifier: models.CriterionModifierIncludes,
|
||||||
|
Value: value1,
|
||||||
|
}, column))
|
||||||
|
|
||||||
|
assert.Len(f.whereClauses, 1)
|
||||||
|
assert.Equal(fmt.Sprintf("(%[1]s LIKE ? OR %[1]s LIKE ?)", column), f.whereClauses[0].sql)
|
||||||
|
assert.Len(f.whereClauses[0].args, 2)
|
||||||
|
assert.Equal("%two%", f.whereClauses[0].args[0])
|
||||||
|
assert.Equal("%words%", f.whereClauses[0].args[1])
|
||||||
|
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.handleCriterionFunc(stringCriterionHandler(&models.StringCriterionInput{
|
||||||
|
Modifier: models.CriterionModifierIncludes,
|
||||||
|
Value: quotedValue,
|
||||||
|
}, column))
|
||||||
|
|
||||||
|
assert.Len(f.whereClauses, 1)
|
||||||
|
assert.Equal(fmt.Sprintf("(%[1]s LIKE ?)", column), f.whereClauses[0].sql)
|
||||||
|
assert.Len(f.whereClauses[0].args, 1)
|
||||||
|
assert.Equal("%two words%", f.whereClauses[0].args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringCriterionHandlerExcludes(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
const column = "column"
|
||||||
|
const value1 = "two words"
|
||||||
|
const quotedValue = `"two words"`
|
||||||
|
|
||||||
|
f := &filterBuilder{}
|
||||||
|
f.handleCriterionFunc(stringCriterionHandler(&models.StringCriterionInput{
|
||||||
|
Modifier: models.CriterionModifierExcludes,
|
||||||
|
Value: value1,
|
||||||
|
}, column))
|
||||||
|
|
||||||
|
assert.Len(f.whereClauses, 1)
|
||||||
|
assert.Equal(fmt.Sprintf("(%[1]s NOT LIKE ? AND %[1]s NOT LIKE ?)", column), f.whereClauses[0].sql)
|
||||||
|
assert.Len(f.whereClauses[0].args, 2)
|
||||||
|
assert.Equal("%two%", f.whereClauses[0].args[0])
|
||||||
|
assert.Equal("%words%", f.whereClauses[0].args[1])
|
||||||
|
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.handleCriterionFunc(stringCriterionHandler(&models.StringCriterionInput{
|
||||||
|
Modifier: models.CriterionModifierExcludes,
|
||||||
|
Value: quotedValue,
|
||||||
|
}, column))
|
||||||
|
|
||||||
|
assert.Len(f.whereClauses, 1)
|
||||||
|
assert.Equal(fmt.Sprintf("(%[1]s NOT LIKE ?)", column), f.whereClauses[0].sql)
|
||||||
|
assert.Len(f.whereClauses[0].args, 1)
|
||||||
|
assert.Equal("%two words%", f.whereClauses[0].args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringCriterionHandlerEquals(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
const column = "column"
|
||||||
|
const value1 = "two words"
|
||||||
|
|
||||||
|
f := &filterBuilder{}
|
||||||
|
f.handleCriterionFunc(stringCriterionHandler(&models.StringCriterionInput{
|
||||||
|
Modifier: models.CriterionModifierEquals,
|
||||||
|
Value: value1,
|
||||||
|
}, column))
|
||||||
|
|
||||||
|
assert.Len(f.whereClauses, 1)
|
||||||
|
assert.Equal(fmt.Sprintf("%[1]s LIKE ?", column), f.whereClauses[0].sql)
|
||||||
|
assert.Len(f.whereClauses[0].args, 1)
|
||||||
|
assert.Equal(value1, f.whereClauses[0].args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringCriterionHandlerNotEquals(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
const column = "column"
|
||||||
|
const value1 = "two words"
|
||||||
|
|
||||||
|
f := &filterBuilder{}
|
||||||
|
f.handleCriterionFunc(stringCriterionHandler(&models.StringCriterionInput{
|
||||||
|
Modifier: models.CriterionModifierNotEquals,
|
||||||
|
Value: value1,
|
||||||
|
}, column))
|
||||||
|
|
||||||
|
assert.Len(f.whereClauses, 1)
|
||||||
|
assert.Equal(fmt.Sprintf("%[1]s NOT LIKE ?", column), f.whereClauses[0].sql)
|
||||||
|
assert.Len(f.whereClauses[0].args, 1)
|
||||||
|
assert.Equal(value1, f.whereClauses[0].args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringCriterionHandlerMatchesRegex(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
const column = "column"
|
||||||
|
const validValue = "two words"
|
||||||
|
const invalidValue = "*two words"
|
||||||
|
|
||||||
|
f := &filterBuilder{}
|
||||||
|
f.handleCriterionFunc(stringCriterionHandler(&models.StringCriterionInput{
|
||||||
|
Modifier: models.CriterionModifierMatchesRegex,
|
||||||
|
Value: validValue,
|
||||||
|
}, column))
|
||||||
|
|
||||||
|
assert.Len(f.whereClauses, 1)
|
||||||
|
assert.Equal(fmt.Sprintf("%[1]s regexp ?", column), f.whereClauses[0].sql)
|
||||||
|
assert.Len(f.whereClauses[0].args, 1)
|
||||||
|
assert.Equal(validValue, f.whereClauses[0].args[0])
|
||||||
|
|
||||||
|
// ensure invalid regex sets error state
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.handleCriterionFunc(stringCriterionHandler(&models.StringCriterionInput{
|
||||||
|
Modifier: models.CriterionModifierMatchesRegex,
|
||||||
|
Value: invalidValue,
|
||||||
|
}, column))
|
||||||
|
|
||||||
|
assert.NotNil(f.getError())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringCriterionHandlerNotMatchesRegex(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
const column = "column"
|
||||||
|
const validValue = "two words"
|
||||||
|
const invalidValue = "*two words"
|
||||||
|
|
||||||
|
f := &filterBuilder{}
|
||||||
|
f.handleCriterionFunc(stringCriterionHandler(&models.StringCriterionInput{
|
||||||
|
Modifier: models.CriterionModifierNotMatchesRegex,
|
||||||
|
Value: validValue,
|
||||||
|
}, column))
|
||||||
|
|
||||||
|
assert.Len(f.whereClauses, 1)
|
||||||
|
assert.Equal(fmt.Sprintf("%[1]s NOT regexp ?", column), f.whereClauses[0].sql)
|
||||||
|
assert.Len(f.whereClauses[0].args, 1)
|
||||||
|
assert.Equal(validValue, f.whereClauses[0].args[0])
|
||||||
|
|
||||||
|
// ensure invalid regex sets error state
|
||||||
|
f = &filterBuilder{}
|
||||||
|
f.handleCriterionFunc(stringCriterionHandler(&models.StringCriterionInput{
|
||||||
|
Modifier: models.CriterionModifierNotMatchesRegex,
|
||||||
|
Value: invalidValue,
|
||||||
|
}, column))
|
||||||
|
|
||||||
|
assert.NotNil(f.getError())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringCriterionHandlerIsNull(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
const column = "column"
|
||||||
|
|
||||||
|
f := &filterBuilder{}
|
||||||
|
f.handleCriterionFunc(stringCriterionHandler(&models.StringCriterionInput{
|
||||||
|
Modifier: models.CriterionModifierIsNull,
|
||||||
|
}, column))
|
||||||
|
|
||||||
|
assert.Len(f.whereClauses, 1)
|
||||||
|
assert.Equal(fmt.Sprintf("%[1]s IS NULL", column), f.whereClauses[0].sql)
|
||||||
|
assert.Len(f.whereClauses[0].args, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringCriterionHandlerNotNull(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
const column = "column"
|
||||||
|
|
||||||
|
f := &filterBuilder{}
|
||||||
|
f.handleCriterionFunc(stringCriterionHandler(&models.StringCriterionInput{
|
||||||
|
Modifier: models.CriterionModifierNotNull,
|
||||||
|
}, column))
|
||||||
|
|
||||||
|
assert.Len(f.whereClauses, 1)
|
||||||
|
assert.Equal(fmt.Sprintf("%[1]s IS NOT NULL", column), f.whereClauses[0].sql)
|
||||||
|
assert.Len(f.whereClauses[0].args, 0)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ type queryBuilder struct {
|
|||||||
|
|
||||||
body string
|
body string
|
||||||
|
|
||||||
|
joins joins
|
||||||
whereClauses []string
|
whereClauses []string
|
||||||
havingClauses []string
|
havingClauses []string
|
||||||
args []interface{}
|
args []interface{}
|
||||||
@@ -25,7 +26,10 @@ func (qb queryBuilder) executeFind() ([]int, int, error) {
|
|||||||
return nil, 0, qb.err
|
return nil, 0, qb.err
|
||||||
}
|
}
|
||||||
|
|
||||||
return qb.repository.executeFindQuery(qb.body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses)
|
body := qb.body
|
||||||
|
body += qb.joins.toSQL()
|
||||||
|
|
||||||
|
return qb.repository.executeFindQuery(body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (qb *queryBuilder) addWhere(clauses ...string) {
|
func (qb *queryBuilder) addWhere(clauses ...string) {
|
||||||
@@ -48,6 +52,48 @@ func (qb *queryBuilder) addArg(args ...interface{}) {
|
|||||||
qb.args = append(qb.args, args...)
|
qb.args = append(qb.args, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (qb *queryBuilder) join(table, as, onClause string) {
|
||||||
|
newJoin := join{
|
||||||
|
table: table,
|
||||||
|
as: as,
|
||||||
|
onClause: onClause,
|
||||||
|
}
|
||||||
|
|
||||||
|
qb.joins.add(newJoin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (qb *queryBuilder) addJoins(joins ...join) {
|
||||||
|
qb.joins.add(joins...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (qb *queryBuilder) addFilter(f *filterBuilder) {
|
||||||
|
err := f.getError()
|
||||||
|
if err != nil {
|
||||||
|
qb.err = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clause, args := f.generateWhereClauses()
|
||||||
|
if len(clause) > 0 {
|
||||||
|
qb.addWhere(clause)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
qb.addArg(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
clause, args = f.generateHavingClauses()
|
||||||
|
if len(clause) > 0 {
|
||||||
|
qb.addHaving(clause)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
qb.addArg(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
qb.addJoins(f.getAllJoins()...)
|
||||||
|
}
|
||||||
|
|
||||||
func (qb *queryBuilder) handleIntCriterionInput(c *models.IntCriterionInput, column string) {
|
func (qb *queryBuilder) handleIntCriterionInput(c *models.IntCriterionInput, column string) {
|
||||||
if c != nil {
|
if c != nil {
|
||||||
clause, count := getIntCriterionWhereClause(column, *c)
|
clause, count := getIntCriterionWhereClause(column, *c)
|
||||||
|
|||||||
@@ -273,6 +273,18 @@ func (r *repository) newQuery() queryBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *repository) join(j joiner, as string, parentIDCol string) {
|
||||||
|
t := r.tableName
|
||||||
|
if as != "" {
|
||||||
|
t = as
|
||||||
|
}
|
||||||
|
j.addJoin(r.tableName, as, fmt.Sprintf("%s.%s = %s", t, r.idColumn, parentIDCol))
|
||||||
|
}
|
||||||
|
|
||||||
|
type joiner interface {
|
||||||
|
addJoin(table, as, onClause string)
|
||||||
|
}
|
||||||
|
|
||||||
type joinRepository struct {
|
type joinRepository struct {
|
||||||
repository
|
repository
|
||||||
fkColumn string
|
fkColumn string
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ package sqlite
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
@@ -290,51 +289,70 @@ func (qb *sceneQueryBuilder) All() ([]*models.Scene, error) {
|
|||||||
return qb.queryScenes(selectAll(sceneTable)+qb.getSceneSort(nil), nil)
|
return qb.queryScenes(selectAll(sceneTable)+qb.getSceneSort(nil), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryForAutoTag queries for scenes whose paths match the provided regex and
|
func illegalFilterCombination(type1, type2 string) error {
|
||||||
// are optionally within the provided path. Excludes organized scenes.
|
return fmt.Errorf("cannot have %s and %s in the same filter", type1, type2)
|
||||||
// TODO - this should be replaced with Query once it can perform multiple
|
|
||||||
// filters on the same field.
|
|
||||||
func (qb *sceneQueryBuilder) QueryForAutoTag(regex string, pathPrefixes []string) ([]*models.Scene, error) {
|
|
||||||
var args []interface{}
|
|
||||||
body := selectDistinctIDs("scenes") + ` WHERE
|
|
||||||
scenes.path regexp ? AND
|
|
||||||
scenes.organized = 0`
|
|
||||||
|
|
||||||
args = append(args, "(?i)"+regex)
|
|
||||||
|
|
||||||
var pathClauses []string
|
|
||||||
for _, p := range pathPrefixes {
|
|
||||||
pathClauses = append(pathClauses, "scenes.path like ?")
|
|
||||||
|
|
||||||
sep := string(filepath.Separator)
|
|
||||||
if !strings.HasSuffix(p, sep) {
|
|
||||||
p = p + sep
|
|
||||||
}
|
|
||||||
args = append(args, p+"%")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(pathClauses) > 0 {
|
func (qb *sceneQueryBuilder) validateFilter(sceneFilter *models.SceneFilterType) error {
|
||||||
body += " AND (" + strings.Join(pathClauses, " OR ") + ")"
|
const and = "AND"
|
||||||
|
const or = "OR"
|
||||||
|
const not = "NOT"
|
||||||
|
|
||||||
|
if sceneFilter.And != nil {
|
||||||
|
if sceneFilter.Or != nil {
|
||||||
|
return illegalFilterCombination(and, or)
|
||||||
|
}
|
||||||
|
if sceneFilter.Not != nil {
|
||||||
|
return illegalFilterCombination(and, not)
|
||||||
}
|
}
|
||||||
|
|
||||||
idsResult, err := qb.runIdsQuery(body, args)
|
return qb.validateFilter(sceneFilter.And)
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var scenes []*models.Scene
|
if sceneFilter.Or != nil {
|
||||||
for _, id := range idsResult {
|
if sceneFilter.Not != nil {
|
||||||
scene, err := qb.Find(id)
|
return illegalFilterCombination(or, not)
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scenes = append(scenes, scene)
|
return qb.validateFilter(sceneFilter.Or)
|
||||||
}
|
}
|
||||||
|
|
||||||
return scenes, nil
|
if sceneFilter.Not != nil {
|
||||||
|
return qb.validateFilter(sceneFilter.Not)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *filterBuilder {
|
||||||
|
query := &filterBuilder{}
|
||||||
|
|
||||||
|
if sceneFilter.And != nil {
|
||||||
|
query.and(qb.makeFilter(sceneFilter.And))
|
||||||
|
}
|
||||||
|
if sceneFilter.Or != nil {
|
||||||
|
query.or(qb.makeFilter(sceneFilter.Or))
|
||||||
|
}
|
||||||
|
if sceneFilter.Not != nil {
|
||||||
|
query.not(qb.makeFilter(sceneFilter.Not))
|
||||||
|
}
|
||||||
|
|
||||||
|
query.handleCriterionFunc(stringCriterionHandler(sceneFilter.Path, "scenes.path"))
|
||||||
|
query.handleCriterionFunc(intCriterionHandler(sceneFilter.Rating, "scenes.rating"))
|
||||||
|
query.handleCriterionFunc(intCriterionHandler(sceneFilter.OCounter, "scenes.o_counter"))
|
||||||
|
query.handleCriterionFunc(boolCriterionHandler(sceneFilter.Organized, "scenes.organized"))
|
||||||
|
query.handleCriterionFunc(durationCriterionHandler(sceneFilter.Duration, "scenes.duration"))
|
||||||
|
query.handleCriterionFunc(resolutionCriterionHandler(sceneFilter.Resolution, "scenes.height", "scenes.width"))
|
||||||
|
query.handleCriterionFunc(hasMarkersCriterionHandler(sceneFilter.HasMarkers))
|
||||||
|
query.handleCriterionFunc(sceneIsMissingCriterionHandler(qb, sceneFilter.IsMissing))
|
||||||
|
|
||||||
|
query.handleCriterionFunc(sceneTagsCriterionHandler(qb, sceneFilter.Tags))
|
||||||
|
query.handleCriterionFunc(scenePerformersCriterionHandler(qb, sceneFilter.Performers))
|
||||||
|
query.handleCriterionFunc(sceneStudioCriterionHandler(qb, sceneFilter.Studios))
|
||||||
|
query.handleCriterionFunc(sceneMoviesCriterionHandler(qb, sceneFilter.Movies))
|
||||||
|
query.handleCriterionFunc(sceneStashIDsHandler(qb, sceneFilter.StashID))
|
||||||
|
|
||||||
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
func (qb *sceneQueryBuilder) Query(sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) ([]*models.Scene, int, error) {
|
func (qb *sceneQueryBuilder) Query(sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) ([]*models.Scene, int, error) {
|
||||||
@@ -348,152 +366,21 @@ func (qb *sceneQueryBuilder) Query(sceneFilter *models.SceneFilterType, findFilt
|
|||||||
query := qb.newQuery()
|
query := qb.newQuery()
|
||||||
|
|
||||||
query.body = selectDistinctIDs(sceneTable)
|
query.body = selectDistinctIDs(sceneTable)
|
||||||
query.body += `
|
|
||||||
left join scene_markers on scene_markers.scene_id = scenes.id
|
|
||||||
left join performers_scenes as performers_join on performers_join.scene_id = scenes.id
|
|
||||||
left join movies_scenes as movies_join on movies_join.scene_id = scenes.id
|
|
||||||
left join studios as studio on studio.id = scenes.studio_id
|
|
||||||
left join scenes_galleries as galleries_join on galleries_join.scene_id = scenes.id
|
|
||||||
left join scenes_tags as tags_join on tags_join.scene_id = scenes.id
|
|
||||||
left join scene_stash_ids on scene_stash_ids.scene_id = scenes.id
|
|
||||||
`
|
|
||||||
|
|
||||||
if q := findFilter.Q; q != nil && *q != "" {
|
if q := findFilter.Q; q != nil && *q != "" {
|
||||||
|
query.join("scene_markers", "", "scene_markers.scene_id = scenes.id")
|
||||||
searchColumns := []string{"scenes.title", "scenes.details", "scenes.path", "scenes.oshash", "scenes.checksum", "scene_markers.title"}
|
searchColumns := []string{"scenes.title", "scenes.details", "scenes.path", "scenes.oshash", "scenes.checksum", "scene_markers.title"}
|
||||||
clause, thisArgs := getSearchBinding(searchColumns, *q, false)
|
clause, thisArgs := getSearchBinding(searchColumns, *q, false)
|
||||||
query.addWhere(clause)
|
query.addWhere(clause)
|
||||||
query.addArg(thisArgs...)
|
query.addArg(thisArgs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
query.handleStringCriterionInput(sceneFilter.Path, "scenes.path")
|
if err := qb.validateFilter(sceneFilter); err != nil {
|
||||||
query.handleIntCriterionInput(sceneFilter.Rating, "scenes.rating")
|
return nil, 0, err
|
||||||
query.handleIntCriterionInput(sceneFilter.OCounter, "scenes.o_counter")
|
}
|
||||||
|
filter := qb.makeFilter(sceneFilter)
|
||||||
|
|
||||||
if Organized := sceneFilter.Organized; Organized != nil {
|
query.addFilter(filter)
|
||||||
var organized string
|
|
||||||
if *Organized == true {
|
|
||||||
organized = "1"
|
|
||||||
} else {
|
|
||||||
organized = "0"
|
|
||||||
}
|
|
||||||
query.addWhere("scenes.organized = " + organized)
|
|
||||||
}
|
|
||||||
|
|
||||||
if durationFilter := sceneFilter.Duration; durationFilter != nil {
|
|
||||||
clause, thisArgs := getDurationWhereClause(*durationFilter)
|
|
||||||
query.addWhere(clause)
|
|
||||||
query.addArg(thisArgs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resolutionFilter := sceneFilter.Resolution; resolutionFilter != nil {
|
|
||||||
if resolution := resolutionFilter.String(); resolutionFilter.IsValid() {
|
|
||||||
switch resolution {
|
|
||||||
case "VERY_LOW":
|
|
||||||
query.addWhere("MIN(scenes.height, scenes.width) < 240")
|
|
||||||
case "LOW":
|
|
||||||
query.addWhere("(MIN(scenes.height, scenes.width) >= 240 AND MIN(scenes.height, scenes.width) < 360)")
|
|
||||||
case "R360P":
|
|
||||||
query.addWhere("(MIN(scenes.height, scenes.width) >= 360 AND MIN(scenes.height, scenes.width) < 480)")
|
|
||||||
case "STANDARD":
|
|
||||||
query.addWhere("(MIN(scenes.height, scenes.width) >= 480 AND MIN(scenes.height, scenes.width) < 540)")
|
|
||||||
case "WEB_HD":
|
|
||||||
query.addWhere("(MIN(scenes.height, scenes.width) >= 540 AND MIN(scenes.height, scenes.width) < 720)")
|
|
||||||
case "STANDARD_HD":
|
|
||||||
query.addWhere("(MIN(scenes.height, scenes.width) >= 720 AND MIN(scenes.height, scenes.width) < 1080)")
|
|
||||||
case "FULL_HD":
|
|
||||||
query.addWhere("(MIN(scenes.height, scenes.width) >= 1080 AND MIN(scenes.height, scenes.width) < 1440)")
|
|
||||||
case "QUAD_HD":
|
|
||||||
query.addWhere("(MIN(scenes.height, scenes.width) >= 1440 AND MIN(scenes.height, scenes.width) < 1920)")
|
|
||||||
case "VR_HD":
|
|
||||||
query.addWhere("(MIN(scenes.height, scenes.width) >= 1920 AND MIN(scenes.height, scenes.width) < 2160)")
|
|
||||||
case "FOUR_K":
|
|
||||||
query.addWhere("(MIN(scenes.height, scenes.width) >= 2160 AND MIN(scenes.height, scenes.width) < 2880)")
|
|
||||||
case "FIVE_K":
|
|
||||||
query.addWhere("(MIN(scenes.height, scenes.width) >= 2880 AND MIN(scenes.height, scenes.width) < 3384)")
|
|
||||||
case "SIX_K":
|
|
||||||
query.addWhere("(MIN(scenes.height, scenes.width) >= 3384 AND MIN(scenes.height, scenes.width) < 4320)")
|
|
||||||
case "EIGHT_K":
|
|
||||||
query.addWhere("MIN(scenes.height, scenes.width) >= 4320")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasMarkersFilter := sceneFilter.HasMarkers; hasMarkersFilter != nil {
|
|
||||||
if strings.Compare(*hasMarkersFilter, "true") == 0 {
|
|
||||||
query.addHaving("count(scene_markers.scene_id) > 0")
|
|
||||||
} else {
|
|
||||||
query.addWhere("scene_markers.id IS NULL")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isMissingFilter := sceneFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
|
|
||||||
switch *isMissingFilter {
|
|
||||||
case "galleries":
|
|
||||||
query.addWhere("galleries_join.scene_id IS NULL")
|
|
||||||
case "studio":
|
|
||||||
query.addWhere("scenes.studio_id IS NULL")
|
|
||||||
case "movie":
|
|
||||||
query.addWhere("movies_join.scene_id IS NULL")
|
|
||||||
case "performers":
|
|
||||||
query.addWhere("performers_join.scene_id IS NULL")
|
|
||||||
case "date":
|
|
||||||
query.addWhere("scenes.date IS \"\" OR scenes.date IS \"0001-01-01\"")
|
|
||||||
case "tags":
|
|
||||||
query.addWhere("tags_join.scene_id IS NULL")
|
|
||||||
case "stash_id":
|
|
||||||
query.addWhere("scene_stash_ids.scene_id IS NULL")
|
|
||||||
default:
|
|
||||||
query.addWhere("(scenes." + *isMissingFilter + " IS NULL OR TRIM(scenes." + *isMissingFilter + ") = '')")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if tagsFilter := sceneFilter.Tags; tagsFilter != nil && len(tagsFilter.Value) > 0 {
|
|
||||||
for _, tagID := range tagsFilter.Value {
|
|
||||||
query.addArg(tagID)
|
|
||||||
}
|
|
||||||
|
|
||||||
query.body += " LEFT JOIN tags on tags_join.tag_id = tags.id"
|
|
||||||
whereClause, havingClause := getMultiCriterionClause("scenes", "tags", "scenes_tags", "scene_id", "tag_id", tagsFilter)
|
|
||||||
query.addWhere(whereClause)
|
|
||||||
query.addHaving(havingClause)
|
|
||||||
}
|
|
||||||
|
|
||||||
if performersFilter := sceneFilter.Performers; performersFilter != nil && len(performersFilter.Value) > 0 {
|
|
||||||
for _, performerID := range performersFilter.Value {
|
|
||||||
query.addArg(performerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
query.body += " LEFT JOIN performers ON performers_join.performer_id = performers.id"
|
|
||||||
whereClause, havingClause := getMultiCriterionClause("scenes", "performers", "performers_scenes", "scene_id", "performer_id", performersFilter)
|
|
||||||
query.addWhere(whereClause)
|
|
||||||
query.addHaving(havingClause)
|
|
||||||
}
|
|
||||||
|
|
||||||
if studiosFilter := sceneFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 {
|
|
||||||
for _, studioID := range studiosFilter.Value {
|
|
||||||
query.addArg(studioID)
|
|
||||||
}
|
|
||||||
|
|
||||||
whereClause, havingClause := getMultiCriterionClause("scenes", "studio", "", "", "studio_id", studiosFilter)
|
|
||||||
query.addWhere(whereClause)
|
|
||||||
query.addHaving(havingClause)
|
|
||||||
}
|
|
||||||
|
|
||||||
if moviesFilter := sceneFilter.Movies; moviesFilter != nil && len(moviesFilter.Value) > 0 {
|
|
||||||
for _, movieID := range moviesFilter.Value {
|
|
||||||
query.addArg(movieID)
|
|
||||||
}
|
|
||||||
|
|
||||||
query.body += " LEFT JOIN movies ON movies_join.movie_id = movies.id"
|
|
||||||
whereClause, havingClause := getMultiCriterionClause("scenes", "movies", "movies_scenes", "scene_id", "movie_id", moviesFilter)
|
|
||||||
query.addWhere(whereClause)
|
|
||||||
query.addHaving(havingClause)
|
|
||||||
}
|
|
||||||
|
|
||||||
if stashIDFilter := sceneFilter.StashID; stashIDFilter != nil {
|
|
||||||
query.addWhere("scene_stash_ids.stash_id = ?")
|
|
||||||
query.addArg(stashIDFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
query.sortAndPagination = qb.getSceneSort(findFilter) + getPagination(findFilter)
|
query.sortAndPagination = qb.getSceneSort(findFilter) + getPagination(findFilter)
|
||||||
|
|
||||||
@@ -522,7 +409,16 @@ func appendClause(clauses []string, clause string) []string {
|
|||||||
return clauses
|
return clauses
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDurationWhereClause(durationFilter models.IntCriterionInput) (string, []interface{}) {
|
func durationCriterionHandler(durationFilter *models.IntCriterionInput, column string) criterionHandlerFunc {
|
||||||
|
return func(f *filterBuilder) {
|
||||||
|
if durationFilter != nil {
|
||||||
|
clause, thisArgs := getDurationWhereClause(*durationFilter, column)
|
||||||
|
f.addWhere(clause, thisArgs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDurationWhereClause(durationFilter models.IntCriterionInput, column string) (string, []interface{}) {
|
||||||
// special case for duration. We accept duration as seconds as int but the
|
// special case for duration. We accept duration as seconds as int but the
|
||||||
// field is floating point. Change the equals filter to return a range
|
// field is floating point. Change the equals filter to return a range
|
||||||
// between x and x + 1
|
// between x and x + 1
|
||||||
@@ -532,16 +428,16 @@ func getDurationWhereClause(durationFilter models.IntCriterionInput) (string, []
|
|||||||
|
|
||||||
value := durationFilter.Value
|
value := durationFilter.Value
|
||||||
if durationFilter.Modifier == models.CriterionModifierEquals {
|
if durationFilter.Modifier == models.CriterionModifierEquals {
|
||||||
clause = "scenes.duration >= ? AND scenes.duration < ?"
|
clause = fmt.Sprintf("%[1]s >= ? AND %[1]s < ?", column)
|
||||||
args = append(args, value)
|
args = append(args, value)
|
||||||
args = append(args, value+1)
|
args = append(args, value+1)
|
||||||
} else if durationFilter.Modifier == models.CriterionModifierNotEquals {
|
} else if durationFilter.Modifier == models.CriterionModifierNotEquals {
|
||||||
clause = "(scenes.duration < ? OR scenes.duration >= ?)"
|
clause = fmt.Sprintf("(%[1]s < ? OR %[1]s >= ?)", column)
|
||||||
args = append(args, value)
|
args = append(args, value)
|
||||||
args = append(args, value+1)
|
args = append(args, value+1)
|
||||||
} else {
|
} else {
|
||||||
var count int
|
var count int
|
||||||
clause, count = getIntCriterionWhereClause("scenes.duration", durationFilter)
|
clause, count = getIntCriterionWhereClause(column, durationFilter)
|
||||||
if count == 1 {
|
if count == 1 {
|
||||||
args = append(args, value)
|
args = append(args, value)
|
||||||
}
|
}
|
||||||
@@ -550,6 +446,125 @@ func getDurationWhereClause(durationFilter models.IntCriterionInput) (string, []
|
|||||||
return clause, args
|
return clause, args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolutionCriterionHandler(resolution *models.ResolutionEnum, heightColumn string, widthColumn string) criterionHandlerFunc {
|
||||||
|
return func(f *filterBuilder) {
|
||||||
|
if resolution != nil && resolution.IsValid() {
|
||||||
|
min := resolution.GetMinResolution()
|
||||||
|
max := resolution.GetMaxResolution()
|
||||||
|
|
||||||
|
widthHeight := fmt.Sprintf("MIN(%s, %s)", widthColumn, heightColumn)
|
||||||
|
|
||||||
|
if min > 0 {
|
||||||
|
f.addWhere(widthHeight + " >= " + strconv.Itoa(min))
|
||||||
|
}
|
||||||
|
|
||||||
|
if max > 0 {
|
||||||
|
f.addWhere(widthHeight + " < " + strconv.Itoa(max))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasMarkersCriterionHandler(hasMarkers *string) criterionHandlerFunc {
|
||||||
|
return func(f *filterBuilder) {
|
||||||
|
if hasMarkers != nil {
|
||||||
|
f.addJoin("scene_markers", "", "scene_markers.scene_id = scenes.id")
|
||||||
|
if *hasMarkers == "true" {
|
||||||
|
f.addHaving("count(scene_markers.scene_id) > 0")
|
||||||
|
} else {
|
||||||
|
f.addWhere("scene_markers.id IS NULL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceneIsMissingCriterionHandler(qb *sceneQueryBuilder, isMissing *string) criterionHandlerFunc {
|
||||||
|
return func(f *filterBuilder) {
|
||||||
|
if isMissing != nil && *isMissing != "" {
|
||||||
|
switch *isMissing {
|
||||||
|
case "galleries":
|
||||||
|
qb.galleriesRepository().join(f, "galleries_join", "scenes.id")
|
||||||
|
f.addWhere("galleries_join.scene_id IS NULL")
|
||||||
|
case "studio":
|
||||||
|
f.addWhere("scenes.studio_id IS NULL")
|
||||||
|
case "movie":
|
||||||
|
qb.moviesRepository().join(f, "movies_join", "scenes.id")
|
||||||
|
f.addWhere("movies_join.scene_id IS NULL")
|
||||||
|
case "performers":
|
||||||
|
qb.performersRepository().join(f, "performers_join", "scenes.id")
|
||||||
|
f.addWhere("performers_join.scene_id IS NULL")
|
||||||
|
case "date":
|
||||||
|
f.addWhere("scenes.date IS \"\" OR scenes.date IS \"0001-01-01\"")
|
||||||
|
case "tags":
|
||||||
|
qb.tagsRepository().join(f, "tags_join", "scenes.id")
|
||||||
|
f.addWhere("tags_join.scene_id IS NULL")
|
||||||
|
case "stash_id":
|
||||||
|
qb.stashIDRepository().join(f, "scene_stash_ids", "scenes.id")
|
||||||
|
f.addWhere("scene_stash_ids.scene_id IS NULL")
|
||||||
|
default:
|
||||||
|
f.addWhere("(scenes." + *isMissing + " IS NULL OR TRIM(scenes." + *isMissing + ") = '')")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (qb *sceneQueryBuilder) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder {
|
||||||
|
return multiCriterionHandlerBuilder{
|
||||||
|
primaryTable: sceneTable,
|
||||||
|
foreignTable: foreignTable,
|
||||||
|
joinTable: joinTable,
|
||||||
|
primaryFK: sceneIDColumn,
|
||||||
|
foreignFK: foreignFK,
|
||||||
|
addJoinsFunc: addJoinsFunc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func sceneTagsCriterionHandler(qb *sceneQueryBuilder, tags *models.MultiCriterionInput) criterionHandlerFunc {
|
||||||
|
addJoinsFunc := func(f *filterBuilder) {
|
||||||
|
qb.tagsRepository().join(f, "tags_join", "scenes.id")
|
||||||
|
f.addJoin("tags", "", "tags_join.tag_id = tags.id")
|
||||||
|
}
|
||||||
|
h := qb.getMultiCriterionHandlerBuilder(tagTable, scenesTagsTable, tagIDColumn, addJoinsFunc)
|
||||||
|
|
||||||
|
return h.handler(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scenePerformersCriterionHandler(qb *sceneQueryBuilder, performers *models.MultiCriterionInput) criterionHandlerFunc {
|
||||||
|
addJoinsFunc := func(f *filterBuilder) {
|
||||||
|
qb.performersRepository().join(f, "performers_join", "scenes.id")
|
||||||
|
f.addJoin("performers", "", "performers_join.performer_id = performers.id")
|
||||||
|
}
|
||||||
|
h := qb.getMultiCriterionHandlerBuilder(performerTable, performersScenesTable, performerIDColumn, addJoinsFunc)
|
||||||
|
|
||||||
|
return h.handler(performers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceneStudioCriterionHandler(qb *sceneQueryBuilder, studios *models.MultiCriterionInput) criterionHandlerFunc {
|
||||||
|
addJoinsFunc := func(f *filterBuilder) {
|
||||||
|
f.addJoin("studios", "studio", "studio.id = scenes.studio_id")
|
||||||
|
}
|
||||||
|
h := qb.getMultiCriterionHandlerBuilder("studio", "", studioIDColumn, addJoinsFunc)
|
||||||
|
|
||||||
|
return h.handler(studios)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceneMoviesCriterionHandler(qb *sceneQueryBuilder, movies *models.MultiCriterionInput) criterionHandlerFunc {
|
||||||
|
addJoinsFunc := func(f *filterBuilder) {
|
||||||
|
qb.moviesRepository().join(f, "movies_join", "scenes.id")
|
||||||
|
f.addJoin("movies", "", "movies_join.movie_id = movies.id")
|
||||||
|
}
|
||||||
|
h := qb.getMultiCriterionHandlerBuilder(movieTable, moviesScenesTable, "movie_id", addJoinsFunc)
|
||||||
|
return h.handler(movies)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceneStashIDsHandler(qb *sceneQueryBuilder, stashID *string) criterionHandlerFunc {
|
||||||
|
return func(f *filterBuilder) {
|
||||||
|
if stashID != nil && *stashID != "" {
|
||||||
|
qb.stashIDRepository().join(f, "scene_stash_ids", "scenes.id")
|
||||||
|
stringLiteralCriterionHandler(stashID, "scene_stash_ids.stash_id")(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (qb *sceneQueryBuilder) getSceneSort(findFilter *models.FindFilterType) string {
|
func (qb *sceneQueryBuilder) getSceneSort(findFilter *models.FindFilterType) string {
|
||||||
if findFilter == nil {
|
if findFilter == nil {
|
||||||
return " ORDER BY scenes.path, scenes.date ASC "
|
return " ORDER BY scenes.path, scenes.date ASC "
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ func TestSceneQueryQ(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func queryScene(t *testing.T, sqb models.SceneReader, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) []*models.Scene {
|
func queryScene(t *testing.T, sqb models.SceneReader, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) []*models.Scene {
|
||||||
|
t.Helper()
|
||||||
scenes, _, err := sqb.Query(sceneFilter, findFilter)
|
scenes, _, err := sqb.Query(sceneFilter, findFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Error querying scene: %s", err.Error())
|
t.Errorf("Error querying scene: %s", err.Error())
|
||||||
@@ -186,6 +187,143 @@ func TestSceneQueryPath(t *testing.T) {
|
|||||||
verifyScenesPath(t, pathCriterion)
|
verifyScenesPath(t, pathCriterion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSceneQueryPathOr(t *testing.T) {
|
||||||
|
const scene1Idx = 1
|
||||||
|
const scene2Idx = 2
|
||||||
|
|
||||||
|
scene1Path := getSceneStringValue(scene1Idx, "Path")
|
||||||
|
scene2Path := getSceneStringValue(scene2Idx, "Path")
|
||||||
|
|
||||||
|
sceneFilter := models.SceneFilterType{
|
||||||
|
Path: &models.StringCriterionInput{
|
||||||
|
Value: scene1Path,
|
||||||
|
Modifier: models.CriterionModifierEquals,
|
||||||
|
},
|
||||||
|
Or: &models.SceneFilterType{
|
||||||
|
Path: &models.StringCriterionInput{
|
||||||
|
Value: scene2Path,
|
||||||
|
Modifier: models.CriterionModifierEquals,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
withTxn(func(r models.Repository) error {
|
||||||
|
sqb := r.Scene()
|
||||||
|
|
||||||
|
scenes := queryScene(t, sqb, &sceneFilter, nil)
|
||||||
|
|
||||||
|
assert.Len(t, scenes, 2)
|
||||||
|
assert.Equal(t, scene1Path, scenes[0].Path)
|
||||||
|
assert.Equal(t, scene2Path, scenes[1].Path)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSceneQueryPathAndRating(t *testing.T) {
|
||||||
|
const sceneIdx = 1
|
||||||
|
scenePath := getSceneStringValue(sceneIdx, "Path")
|
||||||
|
sceneRating := getRating(sceneIdx)
|
||||||
|
|
||||||
|
sceneFilter := models.SceneFilterType{
|
||||||
|
Path: &models.StringCriterionInput{
|
||||||
|
Value: scenePath,
|
||||||
|
Modifier: models.CriterionModifierEquals,
|
||||||
|
},
|
||||||
|
And: &models.SceneFilterType{
|
||||||
|
Rating: &models.IntCriterionInput{
|
||||||
|
Value: int(sceneRating.Int64),
|
||||||
|
Modifier: models.CriterionModifierEquals,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
withTxn(func(r models.Repository) error {
|
||||||
|
sqb := r.Scene()
|
||||||
|
|
||||||
|
scenes := queryScene(t, sqb, &sceneFilter, nil)
|
||||||
|
|
||||||
|
assert.Len(t, scenes, 1)
|
||||||
|
assert.Equal(t, scenePath, scenes[0].Path)
|
||||||
|
assert.Equal(t, sceneRating.Int64, scenes[0].Rating.Int64)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSceneQueryPathNotRating(t *testing.T) {
|
||||||
|
const sceneIdx = 1
|
||||||
|
|
||||||
|
sceneRating := getRating(sceneIdx)
|
||||||
|
|
||||||
|
pathCriterion := models.StringCriterionInput{
|
||||||
|
Value: "scene_.*1_Path",
|
||||||
|
Modifier: models.CriterionModifierMatchesRegex,
|
||||||
|
}
|
||||||
|
|
||||||
|
ratingCriterion := models.IntCriterionInput{
|
||||||
|
Value: int(sceneRating.Int64),
|
||||||
|
Modifier: models.CriterionModifierEquals,
|
||||||
|
}
|
||||||
|
|
||||||
|
sceneFilter := models.SceneFilterType{
|
||||||
|
Path: &pathCriterion,
|
||||||
|
Not: &models.SceneFilterType{
|
||||||
|
Rating: &ratingCriterion,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
withTxn(func(r models.Repository) error {
|
||||||
|
sqb := r.Scene()
|
||||||
|
|
||||||
|
scenes := queryScene(t, sqb, &sceneFilter, nil)
|
||||||
|
|
||||||
|
for _, scene := range scenes {
|
||||||
|
verifyString(t, scene.Path, pathCriterion)
|
||||||
|
ratingCriterion.Modifier = models.CriterionModifierNotEquals
|
||||||
|
verifyInt64(t, scene.Rating, ratingCriterion)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSceneIllegalQuery(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
const sceneIdx = 1
|
||||||
|
subFilter := models.SceneFilterType{
|
||||||
|
Path: &models.StringCriterionInput{
|
||||||
|
Value: getSceneStringValue(sceneIdx, "Path"),
|
||||||
|
Modifier: models.CriterionModifierEquals,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
sceneFilter := &models.SceneFilterType{
|
||||||
|
And: &subFilter,
|
||||||
|
Or: &subFilter,
|
||||||
|
}
|
||||||
|
|
||||||
|
withTxn(func(r models.Repository) error {
|
||||||
|
sqb := r.Scene()
|
||||||
|
|
||||||
|
_, _, err := sqb.Query(sceneFilter, nil)
|
||||||
|
assert.NotNil(err)
|
||||||
|
|
||||||
|
sceneFilter.Or = nil
|
||||||
|
sceneFilter.Not = &subFilter
|
||||||
|
_, _, err = sqb.Query(sceneFilter, nil)
|
||||||
|
assert.NotNil(err)
|
||||||
|
|
||||||
|
sceneFilter.And = nil
|
||||||
|
sceneFilter.Or = &subFilter
|
||||||
|
_, _, err = sqb.Query(sceneFilter, nil)
|
||||||
|
assert.NotNil(err)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func verifyScenesPath(t *testing.T, pathCriterion models.StringCriterionInput) {
|
func verifyScenesPath(t *testing.T, pathCriterion models.StringCriterionInput) {
|
||||||
withTxn(func(r models.Repository) error {
|
withTxn(func(r models.Repository) error {
|
||||||
sqb := r.Scene()
|
sqb := r.Scene()
|
||||||
|
|||||||
Reference in New Issue
Block a user