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