diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index bc6fbb8e814..c09a9fd4b02 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -941,8 +941,11 @@ func (qb *FileStore) setQuerySort(query *queryBuilder, findFilter *models.FindFi case "path": // special handling for path query.sortAndPagination += fmt.Sprintf(" ORDER BY folders.path %s, files.basename %[1]s", direction) + query.addGroupBy([]string{"folders.path", "files.basename"}, true) default: - query.sortAndPagination += getSort(sort, direction, "files") + add, agg := getSort(sort, direction, "files") + query.sortAndPagination += add + query.addGroupBy(agg, true) } return nil diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 1624087c9e7..ba4d91e494c 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -838,20 +838,27 @@ func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.F addFileTable() addFolderTable() query.sortAndPagination += fmt.Sprintf(" ORDER BY COALESCE(folders.path, '') || COALESCE(file_folder.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI %s", direction) + query.addGroupBy([]string{"folders.path", "file_folder.path", "files.basename"}, true) case "file_mod_time": sort = "mod_time" addFileTable() - query.sortAndPagination += getSort(sort, direction, fileTable) + add, agg := getSort(sort, direction, fileTable) + query.sortAndPagination += add + query.addGroupBy(agg, true) case "title": addFileTable() addFolderTable() query.sortAndPagination += " ORDER BY COALESCE(galleries.title, files.basename, basename(COALESCE(folders.path, ''))) COLLATE NATURAL_CI " + direction + ", file_folder.path COLLATE NATURAL_CI " + direction + query.addGroupBy([]string{"galleries.title", "files.basename", "folders.path", "file_folder.path"}, true) default: - query.sortAndPagination += getSort(sort, direction, "galleries") + add, agg := getSort(sort, direction, "galleries") + query.sortAndPagination += add + query.addGroupBy(agg, true) } // Whatever the sorting, always use title/id as a final sort query.sortAndPagination += ", COALESCE(galleries.title, cast(galleries.id as text)) COLLATE NATURAL_CI ASC" + query.addGroupBy([]string{"galleries.title", "galleries.id"}, true) return nil } diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index ab19608a493..09794db942d 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -513,23 +513,30 @@ func (qb *GroupStore) setGroupSort(query *queryBuilder, findFilter *models.FindF case "sub_group_order": // sub_group_order is a special sort that sorts by the order_index of the subgroups if query.hasJoin("groups_parents") { - query.sortAndPagination += getSort("order_index", direction, "groups_parents") + add, agg := getSort("order_index", direction, "groups_parents") + query.sortAndPagination += add + query.addGroupBy(agg, true) } else { // this will give unexpected results if the query is not filtered by a parent group and // the group has multiple parents and order indexes query.join(groupRelationsTable, "", "groups.id = groups_relations.sub_id") - query.sortAndPagination += getSort("order_index", direction, groupRelationsTable) + add, agg := getSort("order_index", direction, groupRelationsTable) + query.sortAndPagination += add + query.addGroupBy(agg, true) } case "tag_count": query.sortAndPagination += getCountSort(groupTable, groupsTagsTable, groupIDColumn, direction) case "scenes_count": // generic getSort won't work for this query.sortAndPagination += getCountSort(groupTable, groupsScenesTable, groupIDColumn, direction) default: - query.sortAndPagination += getSort(sort, direction, "groups") + add, agg := getSort(sort, direction, "groups") + query.sortAndPagination += add + query.addGroupBy(agg, true) } // Whatever the sorting, always use name/id as a final sort query.sortAndPagination += ", COALESCE(groups.name, cast(groups.id as text)) COLLATE NATURAL_CI ASC" + query.addGroupBy([]string{"groups.name", "groups.id"}, true) return nil } diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 719d37e0132..4a1e3581c1e 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -969,6 +969,7 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod addFilesJoin() addFolderJoin() sortClause = " ORDER BY COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI " + direction + q.addGroupBy([]string{"folders.path", "files.basename"}, true) case "file_count": sortClause = getCountSort(imageTable, imagesFilesTable, imageIDColumn, direction) case "tag_count": @@ -977,17 +978,23 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod sortClause = getCountSort(imageTable, performersImagesTable, imageIDColumn, direction) case "mod_time", "filesize": addFilesJoin() - sortClause = getSort(sort, direction, "files") + add, agg := getSort(sort, direction, "files") + sortClause = add + q.addGroupBy(agg, true) case "title": addFilesJoin() addFolderJoin() sortClause = " ORDER BY COALESCE(images.title, files.basename) COLLATE NATURAL_CI " + direction + ", folders.path COLLATE NATURAL_CI " + direction + q.addGroupBy([]string{"images.title", "files.basename", "folders.path"}, true) default: - sortClause = getSort(sort, direction, "images") + add, agg := getSort(sort, direction, "images") + sortClause = add + q.addGroupBy(agg, true) } // Whatever the sorting, always use title/id as a final sort sortClause += ", COALESCE(images.title, cast(images.id as text)) COLLATE NATURAL_CI ASC" + q.addGroupBy([]string{"images.title", "images.id"}, true) } q.sortAndPagination = sortClause + getPagination(findFilter) diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 4c953629ad9..f734115393b 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -613,11 +613,13 @@ func (qb *PerformerStore) makeQuery(ctx context.Context, performerFilter *models } var err error - query.sortAndPagination, err = qb.getPerformerSort(findFilter) + var agg []string + query.sortAndPagination, agg, err = qb.getPerformerSort(findFilter) if err != nil { return nil, err } query.sortAndPagination += getPagination(findFilter) + query.addGroupBy(agg, true) return &query, nil } @@ -731,7 +733,7 @@ var performerSortOptions = sortOptions{ "weight", } -func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (string, error) { +func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (string, []string, error) { var sort string var direction string if findFilter == nil { @@ -744,9 +746,10 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := performerSortOptions.validateSort(sort); err != nil { - return "", err + return "", nil, err } + var agg []string sortQuery := "" switch sort { case "tag_count": @@ -766,12 +769,15 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s case "last_o_at": sortQuery += qb.sortByLastOAt(direction) default: - sortQuery += getSort(sort, direction, "performers") + var add string + add, agg = getSort(sort, direction, "performers") + sortQuery += add } // Whatever the sorting, always use name/id as a final sort sortQuery += ", COALESCE(performers.name, cast(performers.id as text)) COLLATE NATURAL_CI ASC" - return sortQuery, nil + agg = append(agg, "performers.name", "performers.id") + return sortQuery, agg, nil } func (qb *PerformerStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { diff --git a/pkg/sqlite/query.go b/pkg/sqlite/query.go index 0a92e44b16b..80327a4ef6b 100644 --- a/pkg/sqlite/query.go +++ b/pkg/sqlite/query.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil" ) type queryBuilder struct { @@ -35,13 +36,15 @@ func (qb queryBuilder) body() string { */ func (qb *queryBuilder) addColumn(column string, nonaggregates []string) { qb.columns = append(qb.columns, column) - if len(nonaggregates) > 0 && dbWrapper.dbType == PostgresBackend { - qb.addGroupBy(nonaggregates) - } + qb.addGroupBy(nonaggregates, dbWrapper.dbType == PostgresBackend) } -func (qb *queryBuilder) addGroupBy(aggregate []string) { - qb.groupByClauses = append(qb.groupByClauses, aggregate...) +func (qb *queryBuilder) addGroupBy(aggregate []string, pgsqlfix bool) { + if !pgsqlfix || len(aggregate) == 0 { + return + } + + qb.groupByClauses = sliceutil.AppendUniques(qb.groupByClauses, aggregate) } func (qb queryBuilder) toSQL(includeSortPagination bool) string { diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 00925bfa1d8..1c349fd4fa1 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -1130,10 +1130,14 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF switch sort { case "movie_scene_number": query.join(groupsScenesTable, "", "scenes.id = groups_scenes.scene_id") - query.sortAndPagination += getSort("scene_index", direction, groupsScenesTable) + add, agg := getSort("scene_index", direction, groupsScenesTable) + query.sortAndPagination += add + query.addGroupBy(agg, true) case "group_scene_number": query.join(groupsScenesTable, "scene_group", "scenes.id = scene_group.scene_id") - query.sortAndPagination += getSort("scene_index", direction, "scene_group") + add, agg := getSort("scene_index", direction, "scene_group") + query.sortAndPagination += add + query.addGroupBy(agg, true) case "tag_count": query.sortAndPagination += getCountSort(sceneTable, scenesTagsTable, sceneIDColumn, direction) case "performer_count": @@ -1145,6 +1149,7 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF addFileTable() addFolderTable() query.sortAndPagination += fmt.Sprintf(" ORDER BY COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI %s", direction) + query.addGroupBy([]string{"folders.path", "files.basename"}, true) case "perceptual_similarity": // special handling for phash addFileTable() @@ -1157,31 +1162,45 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF ) query.sortAndPagination += " ORDER BY fingerprints_phash.fingerprint " + direction + ", files.size DESC" + query.addGroupBy([]string{"fingerprints_phash.fingerprint", "files.size"}, true) case "bitrate": sort = "bit_rate" addVideoFileTable() - query.sortAndPagination += getSort(sort, direction, videoFileTable) + add, agg := getSort(sort, direction, videoFileTable) + query.sortAndPagination += add + query.addGroupBy(agg, true) case "file_mod_time": sort = "mod_time" addFileTable() - query.sortAndPagination += getSort(sort, direction, fileTable) + add, agg := getSort(sort, direction, fileTable) + query.sortAndPagination += add + query.addGroupBy(agg, true) case "framerate": sort = "frame_rate" addVideoFileTable() - query.sortAndPagination += getSort(sort, direction, videoFileTable) + add, agg := getSort(sort, direction, videoFileTable) + query.sortAndPagination += add + query.addGroupBy(agg, true) case "filesize": addFileTable() - query.sortAndPagination += getSort(sort, direction, fileTable) + add, agg := getSort(sort, direction, fileTable) + query.sortAndPagination += add + query.addGroupBy(agg, true) case "duration": addVideoFileTable() - query.sortAndPagination += getSort(sort, direction, videoFileTable) + add, agg := getSort(sort, direction, videoFileTable) + query.sortAndPagination += add + query.addGroupBy(agg, true) case "interactive", "interactive_speed": addVideoFileTable() - query.sortAndPagination += getSort(sort, direction, videoFileTable) + add, agg := getSort(sort, direction, videoFileTable) + query.sortAndPagination += add + query.addGroupBy(agg, true) case "title": addFileTable() addFolderTable() query.sortAndPagination += " ORDER BY COALESCE(scenes.title, files.basename) COLLATE NATURAL_CI " + direction + ", folders.path COLLATE NATURAL_CI " + direction + query.addGroupBy([]string{"scenes.title", "files.basename", "folders.path"}, true) case "play_count": query.sortAndPagination += getCountSort(sceneTable, scenesViewDatesTable, sceneIDColumn, direction) case "last_played_at": @@ -1191,11 +1210,14 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF case "o_counter": query.sortAndPagination += getCountSort(sceneTable, scenesODatesTable, sceneIDColumn, direction) default: - query.sortAndPagination += getSort(sort, direction, "scenes") + add, agg := getSort(sort, direction, "scenes") + query.sortAndPagination += add + query.addGroupBy(agg, true) } // Whatever the sorting, always use title/id as a final sort query.sortAndPagination += ", COALESCE(scenes.title, cast(scenes.id as text)) COLLATE NATURAL_CI ASC" + query.addGroupBy([]string{"scenes.title", "scenes.id"}, true) return nil } diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index 87a849d2084..b08ed31c5e8 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -375,12 +375,16 @@ func (qb *SceneMarkerStore) setSceneMarkerSort(query *queryBuilder, findFilter * case "scenes_updated_at": sort = "updated_at" query.join(sceneTable, "", "scenes.id = scene_markers.scene_id") - query.sortAndPagination += getSort(sort, direction, sceneTable) + add, agg := getSort(sort, direction, sceneTable) + query.sortAndPagination += add + query.addGroupBy(agg, true) case "title": query.join(tagTable, "", "scene_markers.primary_tag_id = tags.id") query.sortAndPagination += " ORDER BY COALESCE(NULLIF(scene_markers.title,''), tags.name) COLLATE NATURAL_CI " + direction default: - query.sortAndPagination += getSort(sort, direction, sceneMarkerTable) + add, agg := getSort(sort, direction, sceneMarkerTable) + query.sortAndPagination += add + query.addGroupBy(agg, true) } query.sortAndPagination += ", scene_markers.scene_id ASC, scene_markers.seconds ASC" diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index df051811460..14c8f0e6eee 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -89,17 +89,20 @@ func getSortDirection(direction string) string { return direction } } -func getSort(sort string, direction string, tableName string) string { +func getSort(sort string, direction string, tableName string) (string, []string) { direction = getSortDirection(direction) + nonaggregates := []string{} switch { case strings.HasSuffix(sort, "_count"): var relationTableName = strings.TrimSuffix(sort, "_count") // TODO: pluralize? colName := getColumn(relationTableName, "id") - return " ORDER BY COUNT(distinct " + colName + ") " + direction + nonaggregates = append(nonaggregates, colName) + return " ORDER BY COUNT(distinct " + colName + ") " + direction, nonaggregates case strings.Compare(sort, "filesize") == 0: colName := getColumn(tableName, "size") - return " ORDER BY " + colName + " " + direction + nonaggregates = append(nonaggregates, colName) + return " ORDER BY " + colName + " " + direction, nonaggregates case strings.HasPrefix(sort, randomSeedPrefix): // seed as a parameter from the UI seedStr := sort[len(randomSeedPrefix):] @@ -108,22 +111,24 @@ func getSort(sort string, direction string, tableName string) string { // fallback to a random seed seed = rand.Uint64() } - return getRandomSort(tableName, direction, seed) + return getRandomSort(tableName, direction, seed), nonaggregates case strings.Compare(sort, "random") == 0: - return getRandomSort(tableName, direction, rand.Uint64()) + return getRandomSort(tableName, direction, rand.Uint64()), nonaggregates default: colName := getColumn(tableName, sort) if strings.Contains(sort, ".") { colName = sort } + nonaggregates = append(nonaggregates, colName) + if strings.Compare(sort, "name") == 0 { - return " ORDER BY " + colName + " COLLATE NATURAL_CI " + direction + return " ORDER BY " + colName + " COLLATE NATURAL_CI " + direction, nonaggregates } if strings.Compare(sort, "title") == 0 { - return " ORDER BY " + colName + " COLLATE NATURAL_CI " + direction + return " ORDER BY " + colName + " COLLATE NATURAL_CI " + direction, nonaggregates } - return " ORDER BY " + colName + " " + direction + return " ORDER BY " + colName + " " + direction, nonaggregates } } diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 26423e41778..154d24f0c26 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -539,11 +539,13 @@ func (qb *StudioStore) makeQuery(ctx context.Context, studioFilter *models.Studi } var err error - query.sortAndPagination, err = qb.getStudioSort(findFilter) + var agg []string + query.sortAndPagination, agg, err = qb.getStudioSort(findFilter) if err != nil { return nil, err } query.sortAndPagination += getPagination(findFilter) + query.addGroupBy(agg, true) return &query, nil } @@ -589,7 +591,7 @@ var studioSortOptions = sortOptions{ "updated_at", } -func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string, error) { +func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string, []string, error) { var sort string var direction string if findFilter == nil { @@ -602,9 +604,10 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string, // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := studioSortOptions.validateSort(sort); err != nil { - return "", err + return "", nil, err } + var agg []string sortQuery := "" switch sort { case "tag_count": @@ -618,12 +621,15 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string, case "child_count": sortQuery += getCountSort(studioTable, studioTable, studioParentIDColumn, direction) default: - sortQuery += getSort(sort, direction, "studios") + var add string + add, agg = getSort(sort, direction, "studios") + sortQuery += add } // Whatever the sorting, always use name/id as a final sort sortQuery += ", COALESCE(studios.name, cast(studios.id as text)) COLLATE NATURAL_CI ASC" - return sortQuery, nil + agg = append(agg, "studios.name", "studios.id") + return sortQuery, agg, nil } func (qb *StudioStore) GetImage(ctx context.Context, studioID int) ([]byte, error) { diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 2f1c05f737e..657cf5a7705 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -383,7 +383,8 @@ func (qb *TagStore) FindBySceneID(ctx context.Context, sceneID int) ([]*models.T WHERE scenes_join.scene_id = ? GROUP BY tags.id ` - query += qb.getDefaultTagSort() + add, _ := qb.getDefaultTagSort() + query += add args := []interface{}{sceneID} return qb.queryTags(ctx, query, args) } @@ -395,7 +396,8 @@ func (qb *TagStore) FindByPerformerID(ctx context.Context, performerID int) ([]* WHERE performers_join.performer_id = ? GROUP BY tags.id ` - query += qb.getDefaultTagSort() + add, _ := qb.getDefaultTagSort() + query += add args := []interface{}{performerID} return qb.queryTags(ctx, query, args) } @@ -407,7 +409,8 @@ func (qb *TagStore) FindByImageID(ctx context.Context, imageID int) ([]*models.T WHERE images_join.image_id = ? GROUP BY tags.id ` - query += qb.getDefaultTagSort() + add, _ := qb.getDefaultTagSort() + query += add args := []interface{}{imageID} return qb.queryTags(ctx, query, args) } @@ -419,7 +422,8 @@ func (qb *TagStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mode WHERE galleries_join.gallery_id = ? GROUP BY tags.id ` - query += qb.getDefaultTagSort() + add, _ := qb.getDefaultTagSort() + query += add args := []interface{}{galleryID} return qb.queryTags(ctx, query, args) } @@ -431,7 +435,8 @@ func (qb *TagStore) FindByGroupID(ctx context.Context, groupID int) ([]*models.T WHERE groups_join.group_id = ? GROUP BY tags.id ` - query += qb.getDefaultTagSort() + add, _ := qb.getDefaultTagSort() + query += add args := []interface{}{groupID} return qb.queryTags(ctx, query, args) } @@ -443,7 +448,8 @@ func (qb *TagStore) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) WHERE scene_markers_join.scene_marker_id = ? GROUP BY tags.id ` - query += qb.getDefaultTagSort() + add, _ := qb.getDefaultTagSort() + query += add args := []interface{}{sceneMarkerID} return qb.queryTags(ctx, query, args) } @@ -455,7 +461,8 @@ func (qb *TagStore) FindByStudioID(ctx context.Context, studioID int) ([]*models WHERE studios_join.studio_id = ? GROUP BY tags.id ` - query += qb.getDefaultTagSort() + add, _ := qb.getDefaultTagSort() + query += add args := []interface{}{studioID} return qb.queryTags(ctx, query, args) } @@ -519,7 +526,8 @@ func (qb *TagStore) FindByParentTagID(ctx context.Context, parentID int) ([]*mod INNER JOIN tags_relations ON tags_relations.child_id = tags.id WHERE tags_relations.parent_id = ? ` - query += qb.getDefaultTagSort() + add, _ := qb.getDefaultTagSort() + query += add args := []interface{}{parentID} return qb.queryTags(ctx, query, args) } @@ -530,7 +538,8 @@ func (qb *TagStore) FindByChildTagID(ctx context.Context, parentID int) ([]*mode INNER JOIN tags_relations ON tags_relations.parent_id = tags.id WHERE tags_relations.child_id = ? ` - query += qb.getDefaultTagSort() + add, _ := qb.getDefaultTagSort() + query += add args := []interface{}{parentID} return qb.queryTags(ctx, query, args) } @@ -616,11 +625,13 @@ func (qb *TagStore) Query(ctx context.Context, tagFilter *models.TagFilterType, } var err error - query.sortAndPagination, err = qb.getTagSort(&query, findFilter) + var agg []string + query.sortAndPagination, agg, err = qb.getTagSort(&query, findFilter) if err != nil { return nil, 0, err } query.sortAndPagination += getPagination(findFilter) + query.addGroupBy(agg, true) idsResult, countResult, err := query.executeFind(ctx) if err != nil { return nil, 0, err @@ -650,11 +661,11 @@ var tagSortOptions = sortOptions{ "updated_at", } -func (qb *TagStore) getDefaultTagSort() string { +func (qb *TagStore) getDefaultTagSort() (string, []string) { return getSort("name", "ASC", "tags") } -func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilterType) (string, error) { +func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilterType) (string, []string, error) { var sort string var direction string if findFilter == nil { @@ -667,10 +678,11 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := tagSortOptions.validateSort(sort); err != nil { - return "", err + return "", nil, err } sortQuery := "" + var agg []string switch sort { case "scenes_count": sortQuery += getCountSort(tagTable, scenesTagsTable, tagIDColumn, direction) @@ -687,12 +699,15 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte case "movies_count", "groups_count": sortQuery += getCountSort(tagTable, groupsTagsTable, tagIDColumn, direction) default: - sortQuery += getSort(sort, direction, "tags") + var add string + add, agg = getSort(sort, direction, "tags") + sortQuery += add } // Whatever the sorting, always use name/id as a final sort sortQuery += ", COALESCE(tags.name, cast(tags.id as text)) COLLATE NATURAL_CI ASC" - return sortQuery, nil + agg = append(agg, "tags.name", "tags.id") + return sortQuery, agg, nil } func (qb *TagStore) queryTags(ctx context.Context, query string, args []interface{}) ([]*models.Tag, error) {