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:
WithoutPants
2021-03-02 11:27:36 +11:00
committed by GitHub
parent 117e6326db
commit 1850a2b533
11 changed files with 1512 additions and 186 deletions

View File

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

View File

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

View 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
}

View File

@@ -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
View 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)
}
}
}

View 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)
}

View File

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

View File

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

View File

@@ -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) func (qb *sceneQueryBuilder) validateFilter(sceneFilter *models.SceneFilterType) error {
const and = "AND"
const or = "OR"
const not = "NOT"
var pathClauses []string if sceneFilter.And != nil {
for _, p := range pathPrefixes { if sceneFilter.Or != nil {
pathClauses = append(pathClauses, "scenes.path like ?") return illegalFilterCombination(and, or)
sep := string(filepath.Separator)
if !strings.HasSuffix(p, sep) {
p = p + sep
} }
args = append(args, p+"%") if sceneFilter.Not != nil {
return illegalFilterCombination(and, not)
} }
if len(pathClauses) > 0 { return qb.validateFilter(sceneFilter.And)
body += " AND (" + strings.Join(pathClauses, " OR ") + ")"
} }
idsResult, err := qb.runIdsQuery(body, args) if sceneFilter.Or != nil {
if sceneFilter.Not != nil {
if err != nil { return illegalFilterCombination(or, not)
return nil, err
} }
var scenes []*models.Scene return qb.validateFilter(sceneFilter.Or)
for _, id := range idsResult {
scene, err := qb.Find(id)
if err != nil {
return nil, err
} }
scenes = append(scenes, scene) if sceneFilter.Not != nil {
return qb.validateFilter(sceneFilter.Not)
} }
return scenes, nil 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 "

View File

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

View File

@@ -103,7 +103,7 @@ func getSearchBinding(columns []string, q string, not bool) (string, []interface
notStr := "" notStr := ""
binaryType := " OR " binaryType := " OR "
if not { if not {
notStr = " NOT " notStr = " NOT"
binaryType = " AND " binaryType = " AND "
} }