diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql
index d03aa062b..1a4f78a39 100644
--- a/graphql/schema/types/filters.graphql
+++ b/graphql/schema/types/filters.graphql
@@ -75,6 +75,10 @@ input SceneMarkerFilterType {
}
input SceneFilterType {
+ AND: SceneFilterType
+ OR: SceneFilterType
+ NOT: SceneFilterType
+
"""Filter by path"""
path: StringCriterionInput
"""Filter by rating"""
diff --git a/pkg/manager/task_autotag.go b/pkg/manager/task_autotag.go
index c9077b50d..cbd7cdc32 100644
--- a/pkg/manager/task_autotag.go
+++ b/pkg/manager/task_autotag.go
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
+ "path/filepath"
"strings"
"sync"
@@ -38,13 +39,56 @@ func (t *AutoTagTask) getQueryRegex(name string) string {
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() {
regex := t.getQueryRegex(t.performer.Name.String)
if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
qb := r.Scene()
- scenes, err := qb.QueryForAutoTag(regex, t.paths)
+ scenes, _, err := qb.Query(t.getQueryFilter(regex), t.getFindFilter())
if err != nil {
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 {
qb := r.Scene()
- scenes, err := qb.QueryForAutoTag(regex, t.paths)
+ scenes, _, err := qb.Query(t.getQueryFilter(regex), t.getFindFilter())
if err != nil {
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 {
qb := r.Scene()
- scenes, err := qb.QueryForAutoTag(regex, t.paths)
+ scenes, _, err := qb.Query(t.getQueryFilter(regex), t.getFindFilter())
if err != nil {
return fmt.Errorf("Error querying scenes with regex '%s': %s", regex, err.Error())
diff --git a/pkg/models/extension_resolution.go b/pkg/models/extension_resolution.go
new file mode 100644
index 000000000..864fd4421
--- /dev/null
+++ b/pkg/models/extension_resolution.go
@@ -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
+}
diff --git a/pkg/models/scene.go b/pkg/models/scene.go
index 6bd5e78f8..ef4485717 100644
--- a/pkg/models/scene.go
+++ b/pkg/models/scene.go
@@ -21,7 +21,6 @@ type SceneReader interface {
CountMissingOSHash() (int, error)
Wall(q *string) ([]*Scene, error)
All() ([]*Scene, error)
- QueryForAutoTag(regex string, pathPrefixes []string) ([]*Scene, error)
Query(sceneFilter *SceneFilterType, findFilter *FindFilterType) ([]*Scene, int, error)
GetCover(sceneID int) ([]byte, error)
GetMovies(sceneID int) ([]MoviesScenes, error)
diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go
new file mode 100644
index 000000000..40b19fbde
--- /dev/null
+++ b/pkg/sqlite/filter.go
@@ -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
[AS ] ON
+// 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)
+ }
+ }
+}
diff --git a/pkg/sqlite/filter_internal_test.go b/pkg/sqlite/filter_internal_test.go
new file mode 100644
index 000000000..957166eba
--- /dev/null
+++ b/pkg/sqlite/filter_internal_test.go
@@ -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)
+}
diff --git a/pkg/sqlite/query.go b/pkg/sqlite/query.go
index a45ab4586..d0d764337 100644
--- a/pkg/sqlite/query.go
+++ b/pkg/sqlite/query.go
@@ -11,6 +11,7 @@ type queryBuilder struct {
body string
+ joins joins
whereClauses []string
havingClauses []string
args []interface{}
@@ -25,7 +26,10 @@ func (qb queryBuilder) executeFind() ([]int, int, error) {
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) {
@@ -48,6 +52,48 @@ func (qb *queryBuilder) addArg(args ...interface{}) {
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) {
if c != nil {
clause, count := getIntCriterionWhereClause(column, *c)
diff --git a/pkg/sqlite/repository.go b/pkg/sqlite/repository.go
index 99d2eeb01..681e68376 100644
--- a/pkg/sqlite/repository.go
+++ b/pkg/sqlite/repository.go
@@ -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 {
repository
fkColumn string
diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go
index 13c7cad01..71828c8c9 100644
--- a/pkg/sqlite/scene.go
+++ b/pkg/sqlite/scene.go
@@ -3,8 +3,7 @@ package sqlite
import (
"database/sql"
"fmt"
- "path/filepath"
- "strings"
+ "strconv"
"github.com/jmoiron/sqlx"
"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)
}
-// QueryForAutoTag queries for scenes whose paths match the provided regex and
-// are optionally within the provided path. Excludes organized scenes.
-// 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`
+func illegalFilterCombination(type1, type2 string) error {
+ return fmt.Errorf("cannot have %s and %s in the same filter", type1, type2)
+}
- args = append(args, "(?i)"+regex)
+func (qb *sceneQueryBuilder) validateFilter(sceneFilter *models.SceneFilterType) error {
+ const and = "AND"
+ const or = "OR"
+ const not = "NOT"
- 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
+ if sceneFilter.And != nil {
+ if sceneFilter.Or != nil {
+ return illegalFilterCombination(and, or)
}
- args = append(args, p+"%")
- }
-
- if len(pathClauses) > 0 {
- body += " AND (" + strings.Join(pathClauses, " OR ") + ")"
- }
-
- idsResult, err := qb.runIdsQuery(body, args)
-
- if err != nil {
- return nil, err
- }
-
- var scenes []*models.Scene
- for _, id := range idsResult {
- scene, err := qb.Find(id)
-
- if err != nil {
- return nil, err
+ if sceneFilter.Not != nil {
+ return illegalFilterCombination(and, not)
}
- scenes = append(scenes, scene)
+ return qb.validateFilter(sceneFilter.And)
}
- return scenes, nil
+ if sceneFilter.Or != nil {
+ if sceneFilter.Not != nil {
+ return illegalFilterCombination(or, not)
+ }
+
+ return qb.validateFilter(sceneFilter.Or)
+ }
+
+ 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) {
@@ -348,152 +366,21 @@ func (qb *sceneQueryBuilder) Query(sceneFilter *models.SceneFilterType, findFilt
query := qb.newQuery()
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 != "" {
+ 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"}
clause, thisArgs := getSearchBinding(searchColumns, *q, false)
query.addWhere(clause)
query.addArg(thisArgs...)
}
- query.handleStringCriterionInput(sceneFilter.Path, "scenes.path")
- query.handleIntCriterionInput(sceneFilter.Rating, "scenes.rating")
- query.handleIntCriterionInput(sceneFilter.OCounter, "scenes.o_counter")
-
- if Organized := sceneFilter.Organized; Organized != nil {
- var organized string
- if *Organized == true {
- organized = "1"
- } else {
- organized = "0"
- }
- query.addWhere("scenes.organized = " + organized)
+ if err := qb.validateFilter(sceneFilter); err != nil {
+ return nil, 0, err
}
+ filter := qb.makeFilter(sceneFilter)
- 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.addFilter(filter)
query.sortAndPagination = qb.getSceneSort(findFilter) + getPagination(findFilter)
@@ -522,7 +409,16 @@ func appendClause(clauses []string, clause string) []string {
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
// field is floating point. Change the equals filter to return a range
// between x and x + 1
@@ -532,16 +428,16 @@ func getDurationWhereClause(durationFilter models.IntCriterionInput) (string, []
value := durationFilter.Value
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+1)
} 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+1)
} else {
var count int
- clause, count = getIntCriterionWhereClause("scenes.duration", durationFilter)
+ clause, count = getIntCriterionWhereClause(column, durationFilter)
if count == 1 {
args = append(args, value)
}
@@ -550,6 +446,125 @@ func getDurationWhereClause(durationFilter models.IntCriterionInput) (string, []
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 {
if findFilter == nil {
return " ORDER BY scenes.path, scenes.date ASC "
diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go
index a4aef4089..4c8c82f8d 100644
--- a/pkg/sqlite/scene_test.go
+++ b/pkg/sqlite/scene_test.go
@@ -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 {
+ t.Helper()
scenes, _, err := sqb.Query(sceneFilter, findFilter)
if err != nil {
t.Errorf("Error querying scene: %s", err.Error())
@@ -186,6 +187,143 @@ func TestSceneQueryPath(t *testing.T) {
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) {
withTxn(func(r models.Repository) error {
sqb := r.Scene()
diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go
index 7736559d2..71b46fe80 100644
--- a/pkg/sqlite/sql.go
+++ b/pkg/sqlite/sql.go
@@ -103,7 +103,7 @@ func getSearchBinding(columns []string, q string, not bool) (string, []interface
notStr := ""
binaryType := " OR "
if not {
- notStr = " NOT "
+ notStr = " NOT"
binaryType = " AND "
}