From 7eff7f02d09b0de6c631533fe95ce804583fd946 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:48:29 +1000 Subject: [PATCH] Add findFolder and findFolders queries to graphql schema (#5965) * Add findFolder and findFolders queries to graphql schema * Add zip file criterion to file and folder queries --- graphql/schema/schema.graphql | 10 ++ graphql/schema/types/file.graphql | 7 +- graphql/schema/types/filters.graphql | 27 +++ internal/api/resolver_query_find_folder.go | 100 ++++++++++++ pkg/models/file.go | 1 + pkg/models/folder.go | 92 +++++++++++ pkg/models/mocks/FolderReaderWriter.go | 23 +++ pkg/models/model_folder.go | 8 + pkg/models/repository_folder.go | 5 + pkg/sqlite/file.go | 2 +- pkg/sqlite/file_filter.go | 38 +++++ pkg/sqlite/file_filter_test.go | 32 +++- pkg/sqlite/folder.go | 181 ++++++++++++++++++++- pkg/sqlite/folder_filter.go | 150 +++++++++++++++++ pkg/sqlite/folder_filter_test.go | 95 +++++++++++ pkg/sqlite/setup_test.go | 41 ++++- 16 files changed, 800 insertions(+), 12 deletions(-) create mode 100644 internal/api/resolver_query_find_folder.go create mode 100644 pkg/models/folder.go create mode 100644 pkg/sqlite/folder_filter.go create mode 100644 pkg/sqlite/folder_filter_test.go diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 1ca653403..7d0a761da 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -16,6 +16,16 @@ type Query { ids: [ID!] ): FindFilesResultType! + "Find a file by its id or path" + findFolder(id: ID, path: String): Folder! + + "Queries for Files" + findFolders( + folder_filter: FolderFilterType + filter: FindFilterType + ids: [ID!] + ): FindFoldersResultType! + "Find a scene by ID or Checksum" findScene(id: ID, checksum: String): Scene findSceneByHash(input: SceneHashInput!): Scene diff --git a/graphql/schema/types/file.graphql b/graphql/schema/types/file.graphql index c967c38f2..835479fad 100644 --- a/graphql/schema/types/file.graphql +++ b/graphql/schema/types/file.graphql @@ -10,7 +10,7 @@ type Folder { parent_folder_id: ID @deprecated(reason: "Use parent_folder instead") zip_file_id: ID @deprecated(reason: "Use zip_file instead") - parent_folder: Folder! + parent_folder: Folder zip_file: BasicFile mod_time: Time! @@ -176,3 +176,8 @@ type FindFilesResultType { files: [BaseFile!]! } + +type FindFoldersResultType { + count: Int! + folders: [Folder!]! +} diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index cab47172e..23ec4ca48 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -691,6 +691,7 @@ input FileFilterType { dir: StringCriterionInput parent_folder: HierarchicalMultiCriterionInput + zip_file: MultiCriterionInput "Filter by modification time" mod_time: TimestampCriterionInput @@ -721,6 +722,32 @@ input FileFilterType { updated_at: TimestampCriterionInput } +input FolderFilterType { + AND: FolderFilterType + OR: FolderFilterType + NOT: FolderFilterType + + path: StringCriterionInput + + parent_folder: HierarchicalMultiCriterionInput + zip_file: MultiCriterionInput + + "Filter by modification time" + mod_time: TimestampCriterionInput + + gallery_count: IntCriterionInput + + "Filter by files that meet this criteria" + files_filter: FileFilterType + "Filter by related galleries that meet this criteria" + galleries_filter: GalleryFilterType + + "Filter by creation time" + created_at: TimestampCriterionInput + "Filter by last update time" + updated_at: TimestampCriterionInput +} + input VideoFileFilterInput { resolution: ResolutionCriterionInput orientation: OrientationCriterionInput diff --git a/internal/api/resolver_query_find_folder.go b/internal/api/resolver_query_find_folder.go new file mode 100644 index 000000000..a7a798dd1 --- /dev/null +++ b/internal/api/resolver_query_find_folder.go @@ -0,0 +1,100 @@ +package api + +import ( + "context" + "errors" + "strconv" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" +) + +func (r *queryResolver) FindFolder(ctx context.Context, id *string, path *string) (*models.Folder, error) { + var ret *models.Folder + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Folder + var err error + switch { + case id != nil: + idInt, err := strconv.Atoi(*id) + if err != nil { + return err + } + ret, err = qb.Find(ctx, models.FolderID(idInt)) + if err != nil { + return err + } + case path != nil: + ret, err = qb.FindByPath(ctx, *path) + if err == nil && ret == nil { + return errors.New("folder not found") + } + default: + return errors.New("either id or path must be provided") + } + + return err + }); err != nil { + return nil, err + } + + return ret, nil +} + +func (r *queryResolver) FindFolders( + ctx context.Context, + folderFilter *models.FolderFilterType, + filter *models.FindFilterType, + ids []string, +) (ret *FindFoldersResultType, err error) { + var folderIDs []models.FolderID + if len(ids) > 0 { + folderIDsInt, err := stringslice.StringSliceToIntSlice(ids) + if err != nil { + return nil, err + } + + folderIDs = models.FolderIDsFromInts(folderIDsInt) + } + + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + var folders []*models.Folder + var err error + + fields := collectQueryFields(ctx) + result := &models.FolderQueryResult{} + + if len(folderIDs) > 0 { + folders, err = r.repository.Folder.FindMany(ctx, folderIDs) + if err == nil { + result.Count = len(folders) + } + } else { + result, err = r.repository.Folder.Query(ctx, models.FolderQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: filter, + Count: fields.Has("count"), + }, + FolderFilter: folderFilter, + }) + if err == nil { + folders, err = result.Resolve(ctx) + } + } + + if err != nil { + return err + } + + ret = &FindFoldersResultType{ + Count: result.Count, + Folders: folders, + } + + return nil + }); err != nil { + return nil, err + } + + return ret, nil +} diff --git a/pkg/models/file.go b/pkg/models/file.go index 1b77af21a..63c30ba4d 100644 --- a/pkg/models/file.go +++ b/pkg/models/file.go @@ -24,6 +24,7 @@ type FileFilterType struct { Basename *StringCriterionInput `json:"basename"` Dir *StringCriterionInput `json:"dir"` ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder"` + ZipFile *MultiCriterionInput `json:"zip_file"` ModTime *TimestampCriterionInput `json:"mod_time"` Duplicated *PHashDuplicationCriterionInput `json:"duplicated"` Hashes []*FingerprintFilterInput `json:"hashes"` diff --git a/pkg/models/folder.go b/pkg/models/folder.go new file mode 100644 index 000000000..ada9e17b7 --- /dev/null +++ b/pkg/models/folder.go @@ -0,0 +1,92 @@ +package models + +import ( + "context" + "path/filepath" + "strings" +) + +type FolderQueryOptions struct { + QueryOptions + FolderFilter *FolderFilterType + + TotalDuration bool + Megapixels bool + TotalSize bool +} + +type FolderFilterType struct { + OperatorFilter[FolderFilterType] + + Path *StringCriterionInput `json:"path,omitempty"` + Basename *StringCriterionInput `json:"basename,omitempty"` + // Filter by parent directory path + Dir *StringCriterionInput `json:"dir,omitempty"` + ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"` + ZipFile *MultiCriterionInput `json:"zip_file,omitempty"` + // Filter by modification time + ModTime *TimestampCriterionInput `json:"mod_time,omitempty"` + GalleryCount *IntCriterionInput `json:"gallery_count,omitempty"` + // Filter by files that meet this criteria + FilesFilter *FileFilterType `json:"files_filter,omitempty"` + // Filter by related galleries that meet this criteria + GalleriesFilter *GalleryFilterType `json:"galleries_filter,omitempty"` + // Filter by creation time + CreatedAt *TimestampCriterionInput `json:"created_at,omitempty"` + // Filter by last update time + UpdatedAt *TimestampCriterionInput `json:"updated_at,omitempty"` +} + +func PathsFolderFilter(paths []string) *FileFilterType { + if paths == nil { + return nil + } + + sep := string(filepath.Separator) + + var ret *FileFilterType + var or *FileFilterType + for _, p := range paths { + newOr := &FileFilterType{} + if or != nil { + or.Or = newOr + } else { + ret = newOr + } + + or = newOr + + if !strings.HasSuffix(p, sep) { + p += sep + } + + or.Path = &StringCriterionInput{ + Modifier: CriterionModifierEquals, + Value: p + "%", + } + } + + return ret +} + +type FolderQueryResult struct { + QueryResult[FolderID] + + getter FolderGetter + folders []*Folder + resolveErr error +} + +func NewFolderQueryResult(folderGetter FolderGetter) *FolderQueryResult { + return &FolderQueryResult{ + getter: folderGetter, + } +} + +func (r *FolderQueryResult) Resolve(ctx context.Context) ([]*Folder, error) { + // cache results + if r.folders == nil && r.resolveErr == nil { + r.folders, r.resolveErr = r.getter.FindMany(ctx, r.IDs) + } + return r.folders, r.resolveErr +} diff --git a/pkg/models/mocks/FolderReaderWriter.go b/pkg/models/mocks/FolderReaderWriter.go index 020764942..512925fd6 100644 --- a/pkg/models/mocks/FolderReaderWriter.go +++ b/pkg/models/mocks/FolderReaderWriter.go @@ -201,6 +201,29 @@ func (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID return r0, r1 } +// Query provides a mock function with given fields: ctx, options +func (_m *FolderReaderWriter) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) { + ret := _m.Called(ctx, options) + + var r0 *models.FolderQueryResult + if rf, ok := ret.Get(0).(func(context.Context, models.FolderQueryOptions) *models.FolderQueryResult); ok { + r0 = rf(ctx, options) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.FolderQueryResult) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, models.FolderQueryOptions) error); ok { + r1 = rf(ctx, options) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Update provides a mock function with given fields: ctx, f func (_m *FolderReaderWriter) Update(ctx context.Context, f *models.Folder) error { ret := _m.Called(ctx, f) diff --git a/pkg/models/model_folder.go b/pkg/models/model_folder.go index 590cdd7bd..39897aa60 100644 --- a/pkg/models/model_folder.go +++ b/pkg/models/model_folder.go @@ -35,6 +35,14 @@ func (i FolderID) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(i.String())) } +func FolderIDsFromInts(ids []int) []FolderID { + ret := make([]FolderID, len(ids)) + for i, id := range ids { + ret[i] = FolderID(id) + } + return ret +} + // Folder represents a folder in the file system. type Folder struct { ID FolderID `json:"id"` diff --git a/pkg/models/repository_folder.go b/pkg/models/repository_folder.go index 20c155ead..671e8780d 100644 --- a/pkg/models/repository_folder.go +++ b/pkg/models/repository_folder.go @@ -17,6 +17,10 @@ type FolderFinder interface { FindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error) } +type FolderQueryer interface { + Query(ctx context.Context, options FolderQueryOptions) (*FolderQueryResult, error) +} + type FolderCounter interface { CountAllInPaths(ctx context.Context, p []string) (int, error) } @@ -48,6 +52,7 @@ type FolderFinderDestroyer interface { // FolderReader provides all methods to read folders. type FolderReader interface { FolderFinder + FolderQueryer FolderCounter } diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index ea2084c2c..ad3442ff7 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -321,7 +321,7 @@ type FileStore struct { func NewFileStore() *FileStore { return &FileStore{ repository: repository{ - tableName: sceneTable, + tableName: fileTable, idColumn: idColumn, }, diff --git a/pkg/sqlite/file_filter.go b/pkg/sqlite/file_filter.go index b115fee35..60ca01648 100644 --- a/pkg/sqlite/file_filter.go +++ b/pkg/sqlite/file_filter.go @@ -68,6 +68,7 @@ func (qb *fileFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{fileFilter.ModTime, "files.mod_time", nil}, qb.parentFolderCriterionHandler(fileFilter.ParentFolder), + qb.zipFileCriterionHandler(fileFilter.ZipFile), qb.sceneCountCriterionHandler(fileFilter.SceneCount), qb.imageCountCriterionHandler(fileFilter.ImageCount), @@ -106,6 +107,43 @@ func (qb *fileFilterHandler) criterionHandler() criterionHandler { } } +func (qb *fileFilterHandler) zipFileCriterionHandler(criterion *models.MultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addWhere(fmt.Sprintf("files.zip_file_id IS %s NULL", notClause)) + return + } + + if len(criterion.Value) == 0 { + return + } + + var args []interface{} + for _, tagID := range criterion.Value { + args = append(args, tagID) + } + + whereClause := "" + havingClause := "" + switch criterion.Modifier { + case models.CriterionModifierIncludes: + whereClause = "files.zip_file_id IN " + getInBinding(len(criterion.Value)) + case models.CriterionModifierExcludes: + whereClause = "files.zip_file_id NOT IN " + getInBinding(len(criterion.Value)) + } + + f.addWhere(whereClause, args...) + f.addHaving(havingClause) + } + } +} + func (qb *fileFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if folder == nil { diff --git a/pkg/sqlite/file_filter_test.go b/pkg/sqlite/file_filter_test.go index 7bc6f3e6b..50eed0129 100644 --- a/pkg/sqlite/file_filter_test.go +++ b/pkg/sqlite/file_filter_test.go @@ -18,7 +18,7 @@ func TestFileQuery(t *testing.T) { findFilter *models.FindFilterType filter *models.FileFilterType includeIdxs []int - includeIDs []int + includeIDs []models.FileID excludeIdxs []int wantErr bool }{ @@ -52,7 +52,7 @@ func TestFileQuery(t *testing.T) { Modifier: models.CriterionModifierIncludes, }, }, - includeIDs: []int{int(sceneFileIDs[sceneIdxWithGroup])}, + includeIDs: []models.FileID{sceneFileIDs[sceneIdxWithGroup]}, excludeIdxs: []int{fileIdxStartImageFiles}, }, { @@ -65,7 +65,20 @@ func TestFileQuery(t *testing.T) { Modifier: models.CriterionModifierIncludes, }, }, - includeIDs: []int{int(sceneFileIDs[sceneIdxWithGroup])}, + includeIDs: []models.FileID{sceneFileIDs[sceneIdxWithGroup]}, + excludeIdxs: []int{fileIdxStartImageFiles}, + }, + { + name: "zip file", + filter: &models.FileFilterType{ + ZipFile: &models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(int(fileIDs[fileIdxZip])), + }, + Modifier: models.CriterionModifierIncludes, + }, + }, + includeIDs: []models.FileID{fileIDs[fileIdxInZip]}, excludeIdxs: []int{fileIdxStartImageFiles}, }, // TODO - add more tests for other file filters @@ -86,15 +99,18 @@ func TestFileQuery(t *testing.T) { return } - include := indexesToIDs(sceneIDs, tt.includeIdxs) - include = append(include, tt.includeIDs...) - exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) + include := indexesToIDPtrs(fileIDs, tt.includeIdxs) + for _, id := range tt.includeIDs { + v := id + include = append(include, &v) + } + exclude := indexesToIDPtrs(fileIDs, tt.excludeIdxs) for _, i := range include { - assert.Contains(results.IDs, models.FileID(i)) + assert.Contains(results.IDs, models.FileID(*i)) } for _, e := range exclude { - assert.NotContains(results.IDs, models.FileID(e)) + assert.NotContains(results.IDs, models.FileID(*e)) } }) } diff --git a/pkg/sqlite/folder.go b/pkg/sqlite/folder.go index f90a578bd..3ac962b8b 100644 --- a/pkg/sqlite/folder.go +++ b/pkg/sqlite/folder.go @@ -16,6 +16,7 @@ import ( ) const folderTable = "folders" +const folderIDColumn = "folder_id" type folderRow struct { ID models.FolderID `db:"id" goqu:"skipinsert"` @@ -83,6 +84,25 @@ func (r folderQueryRows) resolve() []*models.Folder { return ret } +type folderRepositoryType struct { + repository + + galleries repository +} + +var ( + folderRepository = folderRepositoryType{ + repository: repository{ + tableName: folderTable, + idColumn: idColumn, + }, + galleries: repository{ + tableName: galleryTable, + idColumn: folderIDColumn, + }, + } +) + type FolderStore struct { repository @@ -92,7 +112,7 @@ type FolderStore struct { func NewFolderStore() *FolderStore { return &FolderStore{ repository: repository{ - tableName: sceneTable, + tableName: folderTable, idColumn: idColumn, }, @@ -360,3 +380,162 @@ func (qb *FolderStore) FindByZipFileID(ctx context.Context, zipFileID models.Fil return qb.getMany(ctx, q) } + +func (qb *FolderStore) validateFilter(fileFilter *models.FolderFilterType) error { + const and = "AND" + const or = "OR" + const not = "NOT" + + if fileFilter.And != nil { + if fileFilter.Or != nil { + return illegalFilterCombination(and, or) + } + if fileFilter.Not != nil { + return illegalFilterCombination(and, not) + } + + return qb.validateFilter(fileFilter.And) + } + + if fileFilter.Or != nil { + if fileFilter.Not != nil { + return illegalFilterCombination(or, not) + } + + return qb.validateFilter(fileFilter.Or) + } + + if fileFilter.Not != nil { + return qb.validateFilter(fileFilter.Not) + } + + return nil +} + +func (qb *FolderStore) makeFilter(ctx context.Context, folderFilter *models.FolderFilterType) *filterBuilder { + query := &filterBuilder{} + + if folderFilter.And != nil { + query.and(qb.makeFilter(ctx, folderFilter.And)) + } + if folderFilter.Or != nil { + query.or(qb.makeFilter(ctx, folderFilter.Or)) + } + if folderFilter.Not != nil { + query.not(qb.makeFilter(ctx, folderFilter.Not)) + } + + filter := filterBuilderFromHandler(ctx, &folderFilterHandler{ + folderFilter: folderFilter, + }) + + return filter +} + +func (qb *FolderStore) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) { + folderFilter := options.FolderFilter + findFilter := options.FindFilter + + if folderFilter == nil { + folderFilter = &models.FolderFilterType{} + } + if findFilter == nil { + findFilter = &models.FindFilterType{} + } + + query := qb.newQuery() + + distinctIDs(&query, folderTable) + + if q := findFilter.Q; q != nil && *q != "" { + searchColumns := []string{"folders.path"} + query.parseQueryString(searchColumns, *q) + } + + if err := qb.validateFilter(folderFilter); err != nil { + return nil, err + } + filter := qb.makeFilter(ctx, folderFilter) + + if err := query.addFilter(filter); err != nil { + return nil, err + } + + if err := qb.setQuerySort(&query, findFilter); err != nil { + return nil, err + } + query.sortAndPagination += getPagination(findFilter) + + result, err := qb.queryGroupedFields(ctx, options, query) + if err != nil { + return nil, fmt.Errorf("error querying aggregate fields: %w", err) + } + + idsResult, err := query.findIDs(ctx) + if err != nil { + return nil, fmt.Errorf("error finding IDs: %w", err) + } + + result.IDs = make([]models.FolderID, len(idsResult)) + for i, id := range idsResult { + result.IDs[i] = models.FolderID(id) + } + + return result, nil +} + +func (qb *FolderStore) queryGroupedFields(ctx context.Context, options models.FolderQueryOptions, query queryBuilder) (*models.FolderQueryResult, error) { + if !options.Count { + // nothing to do - return empty result + return models.NewFolderQueryResult(qb), nil + } + + aggregateQuery := qb.newQuery() + + if options.Count { + aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total") + } + + const includeSortPagination = false + aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination)) + + out := struct { + Total int + Duration float64 + Megapixels float64 + Size int64 + }{} + if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { + return nil, err + } + + ret := models.NewFolderQueryResult(qb) + ret.Count = out.Total + + return ret, nil +} + +var folderSortOptions = sortOptions{ + "created_at", + "id", + "path", + "random", + "updated_at", +} + +func (qb *FolderStore) setQuerySort(query *queryBuilder, findFilter *models.FindFilterType) error { + if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" { + return nil + } + sort := findFilter.GetSort("path") + + // CVE-2024-32231 - ensure sort is in the list of allowed sorts + if err := folderSortOptions.validateSort(sort); err != nil { + return err + } + + direction := findFilter.GetDirection() + query.sortAndPagination += getSort(sort, direction, "folders") + + return nil +} diff --git a/pkg/sqlite/folder_filter.go b/pkg/sqlite/folder_filter.go new file mode 100644 index 000000000..2fda0d1e3 --- /dev/null +++ b/pkg/sqlite/folder_filter.go @@ -0,0 +1,150 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +type folderFilterHandler struct { + folderFilter *models.FolderFilterType +} + +func (qb *folderFilterHandler) validate() error { + folderFilter := qb.folderFilter + if folderFilter == nil { + return nil + } + + if err := validateFilterCombination(folderFilter.OperatorFilter); err != nil { + return err + } + + if subFilter := folderFilter.SubFilter(); subFilter != nil { + sqb := &folderFilterHandler{folderFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + return nil +} + +func (qb *folderFilterHandler) handle(ctx context.Context, f *filterBuilder) { + folderFilter := qb.folderFilter + if folderFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := folderFilter.SubFilter() + if sf != nil { + sub := &folderFilterHandler{sf} + handleSubFilter(ctx, sub, f, folderFilter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *folderFilterHandler) criterionHandler() criterionHandler { + folderFilter := qb.folderFilter + return compoundHandler{ + stringCriterionHandler(folderFilter.Path, "folders.path"), + ×tampCriterionHandler{folderFilter.ModTime, "folders.mod_time", nil}, + + qb.parentFolderCriterionHandler(folderFilter.ParentFolder), + qb.zipFileCriterionHandler(folderFilter.ZipFile), + + qb.galleryCountCriterionHandler(folderFilter.GalleryCount), + + ×tampCriterionHandler{folderFilter.CreatedAt, "folders.created_at", nil}, + ×tampCriterionHandler{folderFilter.UpdatedAt, "folders.updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "galleries.id", + relatedRepo: galleryRepository.repository, + relatedHandler: &galleryFilterHandler{folderFilter.GalleriesFilter}, + joinFn: func(f *filterBuilder) { + folderRepository.galleries.innerJoin(f, "", "folders.id") + }, + }, + } +} + +func (qb *folderFilterHandler) zipFileCriterionHandler(criterion *models.MultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addWhere(fmt.Sprintf("folders.zip_file_id IS %s NULL", notClause)) + return + } + + if len(criterion.Value) == 0 { + return + } + + var args []interface{} + for _, tagID := range criterion.Value { + args = append(args, tagID) + } + + whereClause := "" + havingClause := "" + switch criterion.Modifier { + case models.CriterionModifierIncludes: + whereClause = "folders.zip_file_id IN " + getInBinding(len(criterion.Value)) + case models.CriterionModifierExcludes: + whereClause = "folders.zip_file_id NOT IN " + getInBinding(len(criterion.Value)) + } + + f.addWhere(whereClause, args...) + f.addHaving(havingClause) + } + } +} + +func (qb *folderFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if folder == nil { + return + } + + folderCopy := *folder + switch folderCopy.Modifier { + case models.CriterionModifierEquals: + folderCopy.Modifier = models.CriterionModifierIncludesAll + case models.CriterionModifierNotEquals: + folderCopy.Modifier = models.CriterionModifierExcludes + } + + hh := hierarchicalMultiCriterionHandlerBuilder{ + primaryTable: folderTable, + foreignTable: folderTable, + foreignFK: "parent_folder_id", + parentFK: "parent_folder_id", + } + + hh.handler(&folderCopy)(ctx, f) + } +} + +func (qb *folderFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if galleryCount != nil { + f.addLeftJoin("galleries", "", "galleries.folder_id = folders.id") + clause, args := getIntCriterionWhereClause("count(distinct galleries.id)", *galleryCount) + + f.addHaving(clause, args...) + } + } +} diff --git a/pkg/sqlite/folder_filter_test.go b/pkg/sqlite/folder_filter_test.go new file mode 100644 index 000000000..c1c7d7a37 --- /dev/null +++ b/pkg/sqlite/folder_filter_test.go @@ -0,0 +1,95 @@ +//go:build integration +// +build integration + +package sqlite_test + +import ( + "context" + "strconv" + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stretchr/testify/assert" +) + +func TestFolderQuery(t *testing.T) { + tests := []struct { + name string + findFilter *models.FindFilterType + filter *models.FolderFilterType + includeIdxs []int + includeIDs []models.FolderID + excludeIdxs []int + wantErr bool + }{ + { + name: "path", + filter: &models.FolderFilterType{ + Path: &models.StringCriterionInput{ + Value: getFolderPath(folderIdxWithSubFolder, nil), + Modifier: models.CriterionModifierIncludes, + }, + }, + includeIdxs: []int{folderIdxWithSubFolder, folderIdxWithParentFolder}, + excludeIdxs: []int{folderIdxInZip}, + }, + { + name: "parent folder", + filter: &models.FolderFilterType{ + ParentFolder: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(int(folderIDs[folderIdxWithSubFolder])), + }, + Modifier: models.CriterionModifierIncludes, + }, + }, + includeIdxs: []int{folderIdxWithParentFolder}, + excludeIdxs: []int{folderIdxWithSubFolder, folderIdxInZip}, + }, + { + name: "zip file", + filter: &models.FolderFilterType{ + ZipFile: &models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(int(fileIDs[fileIdxZip])), + }, + Modifier: models.CriterionModifierIncludes, + }, + }, + includeIdxs: []int{folderIdxInZip}, + excludeIdxs: []int{folderIdxForObjectFiles}, + }, + // TODO - add more tests for other folder filters + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + results, err := db.Folder.Query(ctx, models.FolderQueryOptions{ + FolderFilter: tt.filter, + QueryOptions: models.QueryOptions{ + FindFilter: tt.findFilter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } + + include := indexesToIDPtrs(folderIDs, tt.includeIdxs) + for _, id := range tt.includeIDs { + v := id + include = append(include, &v) + } + exclude := indexesToIDPtrs(folderIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(results.IDs, models.FolderID(*i)) + } + for _, e := range exclude { + assert.NotContains(results.IDs, models.FolderID(*e)) + } + }) + } +} diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 72aeab5bb..a1df897ca 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -578,6 +578,22 @@ func indexToID(ids []int, idx int) int { return ids[idx] } +func indexesToIDPtrs[T any](ids []T, indexes []int) []*T { + ret := make([]*T, len(indexes)) + for i, idx := range indexes { + ret[i] = indexToIDPtr(ids, idx) + } + + return ret +} + +func indexToIDPtr[T any](ids []T, idx int) *T { + if idx < 0 { + return nil + } + return &ids[idx] +} + func indexFromID(ids []int, id int) int { for i, v := range ids { if v == id { @@ -675,7 +691,9 @@ func populateDB() error { return fmt.Errorf("creating files: %w", err) } - // TODO - link folders to zip files + if err := linkFoldersToZip(ctx); err != nil { + return fmt.Errorf("linking folders to zip files: %w", err) + } if err := createTags(ctx, db.Tag, tagsNameCase, tagsNameNoCase); err != nil { return fmt.Errorf("error creating tags: %s", err.Error()) @@ -798,6 +816,27 @@ func createFolders(ctx context.Context) error { return nil } +func linkFoldersToZip(ctx context.Context) error { + // link folders to zip files + for folderIdx, fileIdx := range folderZipFiles { + folderID := folderIDs[folderIdx] + fileID := fileIDs[fileIdx] + + f, err := db.Folder.Find(ctx, folderID) + if err != nil { + return fmt.Errorf("Error finding folder [%d] to link to zip file [%d]", folderID, fileID) + } + + f.ZipFileID = &fileID + + if err := db.Folder.Update(ctx, f); err != nil { + return fmt.Errorf("Error linking folder [%d] to zip file [%d]: %s", folderIdx, fileIdx, err.Error()) + } + } + + return nil +} + func getFileBaseName(index int) string { return getPrefixedStringValue("file", index, "basename") }