mirror of
https://github.com/stashapp/stash.git
synced 2025-12-09 09:53:40 +01:00
* Support Is (not) null for all multi criterions Add support for the Is null and Is not null modifiers for all cases of the MultiCriterionInput and HierarchicalMultiCriterionInput. This partially overlaps the "X Count" filter which sometimes is available (because it would be the same as "X Count equals 0" and "X Count greater than 0") but this also enables it for other criterions like the "Parent Studio" filter for studios or just the "Studios" filter for scenes / images / galleries, the "Movies" filter for scenes etc. * Don't crash UI on bad saved filter * Add missing code for tag parent/child Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
592 lines
17 KiB
Go
592 lines
17 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/stashapp/stash/pkg/models"
|
|
)
|
|
|
|
const imageTable = "images"
|
|
const imageIDColumn = "image_id"
|
|
const performersImagesTable = "performers_images"
|
|
const imagesTagsTable = "images_tags"
|
|
|
|
var imagesForGalleryQuery = selectAll(imageTable) + `
|
|
LEFT JOIN galleries_images as galleries_join on galleries_join.image_id = images.id
|
|
WHERE galleries_join.gallery_id = ?
|
|
GROUP BY images.id
|
|
`
|
|
|
|
var countImagesForGalleryQuery = `
|
|
SELECT gallery_id FROM galleries_images
|
|
WHERE gallery_id = ?
|
|
GROUP BY image_id
|
|
`
|
|
|
|
type imageQueryBuilder struct {
|
|
repository
|
|
}
|
|
|
|
func NewImageReaderWriter(tx dbi) *imageQueryBuilder {
|
|
return &imageQueryBuilder{
|
|
repository{
|
|
tx: tx,
|
|
tableName: imageTable,
|
|
idColumn: idColumn,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) Create(newObject models.Image) (*models.Image, error) {
|
|
var ret models.Image
|
|
if err := qb.insertObject(newObject, &ret); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &ret, nil
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) Update(updatedObject models.ImagePartial) (*models.Image, error) {
|
|
const partial = true
|
|
if err := qb.update(updatedObject.ID, updatedObject, partial); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return qb.find(updatedObject.ID)
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) UpdateFull(updatedObject models.Image) (*models.Image, error) {
|
|
const partial = false
|
|
if err := qb.update(updatedObject.ID, updatedObject, partial); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return qb.find(updatedObject.ID)
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) IncrementOCounter(id int) (int, error) {
|
|
_, err := qb.tx.Exec(
|
|
`UPDATE `+imageTable+` SET o_counter = o_counter + 1 WHERE `+imageTable+`.id = ?`,
|
|
id,
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
image, err := qb.find(id)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return image.OCounter, nil
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) DecrementOCounter(id int) (int, error) {
|
|
_, err := qb.tx.Exec(
|
|
`UPDATE `+imageTable+` SET o_counter = o_counter - 1 WHERE `+imageTable+`.id = ? and `+imageTable+`.o_counter > 0`,
|
|
id,
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
image, err := qb.find(id)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return image.OCounter, nil
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) ResetOCounter(id int) (int, error) {
|
|
_, err := qb.tx.Exec(
|
|
`UPDATE `+imageTable+` SET o_counter = 0 WHERE `+imageTable+`.id = ?`,
|
|
id,
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
image, err := qb.find(id)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return image.OCounter, nil
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) Destroy(id int) error {
|
|
return qb.destroyExisting([]int{id})
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) Find(id int) (*models.Image, error) {
|
|
return qb.find(id)
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) FindMany(ids []int) ([]*models.Image, error) {
|
|
var images []*models.Image
|
|
for _, id := range ids {
|
|
image, err := qb.Find(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if image == nil {
|
|
return nil, fmt.Errorf("image with id %d not found", id)
|
|
}
|
|
|
|
images = append(images, image)
|
|
}
|
|
|
|
return images, nil
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) find(id int) (*models.Image, error) {
|
|
var ret models.Image
|
|
if err := qb.get(id, &ret); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &ret, nil
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) FindByChecksum(checksum string) (*models.Image, error) {
|
|
query := "SELECT * FROM images WHERE checksum = ? LIMIT 1"
|
|
args := []interface{}{checksum}
|
|
return qb.queryImage(query, args)
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) FindByPath(path string) (*models.Image, error) {
|
|
query := selectAll(imageTable) + "WHERE path = ? LIMIT 1"
|
|
args := []interface{}{path}
|
|
return qb.queryImage(query, args)
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) FindByGalleryID(galleryID int) ([]*models.Image, error) {
|
|
args := []interface{}{galleryID}
|
|
return qb.queryImages(imagesForGalleryQuery+qb.getImageSort(nil), args)
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) CountByGalleryID(galleryID int) (int, error) {
|
|
args := []interface{}{galleryID}
|
|
return qb.runCountQuery(qb.buildCountQuery(countImagesForGalleryQuery), args)
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) Count() (int, error) {
|
|
return qb.runCountQuery(qb.buildCountQuery("SELECT images.id FROM images"), nil)
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) Size() (float64, error) {
|
|
return qb.runSumQuery("SELECT SUM(cast(size as double)) as sum FROM images", nil)
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) All() ([]*models.Image, error) {
|
|
return qb.queryImages(selectAll(imageTable)+qb.getImageSort(nil), nil)
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) validateFilter(imageFilter *models.ImageFilterType) error {
|
|
const and = "AND"
|
|
const or = "OR"
|
|
const not = "NOT"
|
|
|
|
if imageFilter.And != nil {
|
|
if imageFilter.Or != nil {
|
|
return illegalFilterCombination(and, or)
|
|
}
|
|
if imageFilter.Not != nil {
|
|
return illegalFilterCombination(and, not)
|
|
}
|
|
|
|
return qb.validateFilter(imageFilter.And)
|
|
}
|
|
|
|
if imageFilter.Or != nil {
|
|
if imageFilter.Not != nil {
|
|
return illegalFilterCombination(or, not)
|
|
}
|
|
|
|
return qb.validateFilter(imageFilter.Or)
|
|
}
|
|
|
|
if imageFilter.Not != nil {
|
|
return qb.validateFilter(imageFilter.Not)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) makeFilter(imageFilter *models.ImageFilterType) *filterBuilder {
|
|
query := &filterBuilder{}
|
|
|
|
if imageFilter.And != nil {
|
|
query.and(qb.makeFilter(imageFilter.And))
|
|
}
|
|
if imageFilter.Or != nil {
|
|
query.or(qb.makeFilter(imageFilter.Or))
|
|
}
|
|
if imageFilter.Not != nil {
|
|
query.not(qb.makeFilter(imageFilter.Not))
|
|
}
|
|
|
|
query.handleCriterion(stringCriterionHandler(imageFilter.Checksum, "images.checksum"))
|
|
query.handleCriterion(stringCriterionHandler(imageFilter.Title, "images.title"))
|
|
query.handleCriterion(stringCriterionHandler(imageFilter.Path, "images.path"))
|
|
query.handleCriterion(intCriterionHandler(imageFilter.Rating, "images.rating"))
|
|
query.handleCriterion(intCriterionHandler(imageFilter.OCounter, "images.o_counter"))
|
|
query.handleCriterion(boolCriterionHandler(imageFilter.Organized, "images.organized"))
|
|
query.handleCriterion(resolutionCriterionHandler(imageFilter.Resolution, "images.height", "images.width"))
|
|
query.handleCriterion(imageIsMissingCriterionHandler(qb, imageFilter.IsMissing))
|
|
|
|
query.handleCriterion(imageTagsCriterionHandler(qb, imageFilter.Tags))
|
|
query.handleCriterion(imageTagCountCriterionHandler(qb, imageFilter.TagCount))
|
|
query.handleCriterion(imageGalleriesCriterionHandler(qb, imageFilter.Galleries))
|
|
query.handleCriterion(imagePerformersCriterionHandler(qb, imageFilter.Performers))
|
|
query.handleCriterion(imagePerformerCountCriterionHandler(qb, imageFilter.PerformerCount))
|
|
query.handleCriterion(imageStudioCriterionHandler(qb, imageFilter.Studios))
|
|
query.handleCriterion(imagePerformerTagsCriterionHandler(qb, imageFilter.PerformerTags))
|
|
|
|
return query
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) makeQuery(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) {
|
|
if imageFilter == nil {
|
|
imageFilter = &models.ImageFilterType{}
|
|
}
|
|
if findFilter == nil {
|
|
findFilter = &models.FindFilterType{}
|
|
}
|
|
|
|
query := qb.newQuery()
|
|
distinctIDs(&query, imageTable)
|
|
|
|
if q := findFilter.Q; q != nil && *q != "" {
|
|
searchColumns := []string{"images.title", "images.path", "images.checksum"}
|
|
clause, thisArgs := getSearchBinding(searchColumns, *q, false)
|
|
query.addWhere(clause)
|
|
query.addArg(thisArgs...)
|
|
}
|
|
|
|
if err := qb.validateFilter(imageFilter); err != nil {
|
|
return nil, err
|
|
}
|
|
filter := qb.makeFilter(imageFilter)
|
|
|
|
query.addFilter(filter)
|
|
|
|
query.sortAndPagination = qb.getImageSort(findFilter) + getPagination(findFilter)
|
|
|
|
return &query, nil
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) Query(options models.ImageQueryOptions) (*models.ImageQueryResult, error) {
|
|
query, err := qb.makeQuery(options.ImageFilter, options.FindFilter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result, err := qb.queryGroupedFields(options, *query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error querying aggregate fields: %w", err)
|
|
}
|
|
|
|
idsResult, err := query.findIDs()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error finding IDs: %w", err)
|
|
}
|
|
|
|
result.IDs = idsResult
|
|
return result, nil
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) queryGroupedFields(options models.ImageQueryOptions, query queryBuilder) (*models.ImageQueryResult, error) {
|
|
if !options.Count && !options.Megapixels && !options.TotalSize {
|
|
// nothing to do - return empty result
|
|
return models.NewImageQueryResult(qb), nil
|
|
}
|
|
|
|
aggregateQuery := qb.newQuery()
|
|
|
|
if options.Count {
|
|
aggregateQuery.addColumn("COUNT(temp.id) as total")
|
|
}
|
|
|
|
if options.Megapixels {
|
|
query.addColumn("COALESCE(images.width, 0) * COALESCE(images.height, 0) / 1000000 as megapixels")
|
|
aggregateQuery.addColumn("COALESCE(SUM(temp.megapixels), 0) as megapixels")
|
|
}
|
|
|
|
if options.TotalSize {
|
|
query.addColumn("COALESCE(images.size, 0) as size")
|
|
aggregateQuery.addColumn("COALESCE(SUM(temp.size), 0) as size")
|
|
}
|
|
|
|
const includeSortPagination = false
|
|
aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination))
|
|
|
|
out := struct {
|
|
Total int
|
|
Megapixels float64
|
|
Size float64
|
|
}{}
|
|
if err := qb.repository.queryStruct(aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ret := models.NewImageQueryResult(qb)
|
|
ret.Count = out.Total
|
|
ret.Megapixels = out.Megapixels
|
|
ret.TotalSize = out.Size
|
|
return ret, nil
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) QueryCount(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (int, error) {
|
|
query, err := qb.makeQuery(imageFilter, findFilter)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return query.executeCount()
|
|
}
|
|
|
|
func imageIsMissingCriterionHandler(qb *imageQueryBuilder, isMissing *string) criterionHandlerFunc {
|
|
return func(f *filterBuilder) {
|
|
if isMissing != nil && *isMissing != "" {
|
|
switch *isMissing {
|
|
case "studio":
|
|
f.addWhere("images.studio_id IS NULL")
|
|
case "performers":
|
|
qb.performersRepository().join(f, "performers_join", "images.id")
|
|
f.addWhere("performers_join.image_id IS NULL")
|
|
case "galleries":
|
|
qb.galleriesRepository().join(f, "galleries_join", "images.id")
|
|
f.addWhere("galleries_join.image_id IS NULL")
|
|
case "tags":
|
|
qb.tagsRepository().join(f, "tags_join", "images.id")
|
|
f.addWhere("tags_join.image_id IS NULL")
|
|
default:
|
|
f.addWhere("(images." + *isMissing + " IS NULL OR TRIM(images." + *isMissing + ") = '')")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder {
|
|
return multiCriterionHandlerBuilder{
|
|
primaryTable: imageTable,
|
|
foreignTable: foreignTable,
|
|
joinTable: joinTable,
|
|
primaryFK: imageIDColumn,
|
|
foreignFK: foreignFK,
|
|
addJoinsFunc: addJoinsFunc,
|
|
}
|
|
}
|
|
|
|
func imageTagsCriterionHandler(qb *imageQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
|
h := joinedHierarchicalMultiCriterionHandlerBuilder{
|
|
tx: qb.tx,
|
|
|
|
primaryTable: imageTable,
|
|
foreignTable: tagTable,
|
|
foreignFK: "tag_id",
|
|
|
|
relationsTable: "tags_relations",
|
|
joinAs: "image_tag",
|
|
joinTable: imagesTagsTable,
|
|
primaryFK: imageIDColumn,
|
|
}
|
|
|
|
return h.handler(tags)
|
|
}
|
|
|
|
func imageTagCountCriterionHandler(qb *imageQueryBuilder, tagCount *models.IntCriterionInput) criterionHandlerFunc {
|
|
h := countCriterionHandlerBuilder{
|
|
primaryTable: imageTable,
|
|
joinTable: imagesTagsTable,
|
|
primaryFK: imageIDColumn,
|
|
}
|
|
|
|
return h.handler(tagCount)
|
|
}
|
|
|
|
func imageGalleriesCriterionHandler(qb *imageQueryBuilder, galleries *models.MultiCriterionInput) criterionHandlerFunc {
|
|
addJoinsFunc := func(f *filterBuilder) {
|
|
qb.galleriesRepository().join(f, "galleries_join", "images.id")
|
|
f.addJoin(galleryTable, "", "galleries_join.gallery_id = galleries.id")
|
|
}
|
|
h := qb.getMultiCriterionHandlerBuilder(galleryTable, galleriesImagesTable, galleryIDColumn, addJoinsFunc)
|
|
|
|
return h.handler(galleries)
|
|
}
|
|
|
|
func imagePerformersCriterionHandler(qb *imageQueryBuilder, performers *models.MultiCriterionInput) criterionHandlerFunc {
|
|
h := joinedMultiCriterionHandlerBuilder{
|
|
primaryTable: imageTable,
|
|
joinTable: performersImagesTable,
|
|
joinAs: "performers_join",
|
|
primaryFK: imageIDColumn,
|
|
foreignFK: performerIDColumn,
|
|
|
|
addJoinTable: func(f *filterBuilder) {
|
|
qb.performersRepository().join(f, "performers_join", "images.id")
|
|
},
|
|
}
|
|
|
|
return h.handler(performers)
|
|
}
|
|
|
|
func imagePerformerCountCriterionHandler(qb *imageQueryBuilder, performerCount *models.IntCriterionInput) criterionHandlerFunc {
|
|
h := countCriterionHandlerBuilder{
|
|
primaryTable: imageTable,
|
|
joinTable: performersImagesTable,
|
|
primaryFK: imageIDColumn,
|
|
}
|
|
|
|
return h.handler(performerCount)
|
|
}
|
|
|
|
func imageStudioCriterionHandler(qb *imageQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
|
h := hierarchicalMultiCriterionHandlerBuilder{
|
|
tx: qb.tx,
|
|
|
|
primaryTable: imageTable,
|
|
foreignTable: studioTable,
|
|
foreignFK: studioIDColumn,
|
|
derivedTable: "studio",
|
|
parentFK: "parent_id",
|
|
}
|
|
|
|
return h.handler(studios)
|
|
}
|
|
|
|
func imagePerformerTagsCriterionHandler(qb *imageQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
|
return func(f *filterBuilder) {
|
|
if tags != nil {
|
|
if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull {
|
|
var notClause string
|
|
if tags.Modifier == models.CriterionModifierNotNull {
|
|
notClause = "NOT"
|
|
}
|
|
|
|
f.addJoin("performers_images", "", "images.id = performers_images.image_id")
|
|
f.addJoin("performers_tags", "", "performers_images.performer_id = performers_tags.performer_id")
|
|
|
|
f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause))
|
|
return
|
|
}
|
|
|
|
if len(tags.Value) == 0 {
|
|
return
|
|
}
|
|
|
|
valuesClause := getHierarchicalValues(qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth)
|
|
|
|
f.addWith(`performer_tags AS (
|
|
SELECT pi.image_id, t.column1 AS root_tag_id FROM performers_images pi
|
|
INNER JOIN performers_tags pt ON pt.performer_id = pi.performer_id
|
|
INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id
|
|
)`)
|
|
|
|
f.addJoin("performer_tags", "", "performer_tags.image_id = images.id")
|
|
|
|
addHierarchicalConditionClauses(f, tags, "performer_tags", "root_tag_id")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) getImageSort(findFilter *models.FindFilterType) string {
|
|
if findFilter == nil {
|
|
return " ORDER BY images.path ASC "
|
|
}
|
|
sort := findFilter.GetSort("title")
|
|
direction := findFilter.GetDirection()
|
|
|
|
switch sort {
|
|
case "tag_count":
|
|
return getCountSort(imageTable, imagesTagsTable, imageIDColumn, direction)
|
|
case "performer_count":
|
|
return getCountSort(imageTable, performersImagesTable, imageIDColumn, direction)
|
|
default:
|
|
return getSort(sort, direction, "images")
|
|
}
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) queryImage(query string, args []interface{}) (*models.Image, error) {
|
|
results, err := qb.queryImages(query, args)
|
|
if err != nil || len(results) < 1 {
|
|
return nil, err
|
|
}
|
|
return results[0], nil
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) queryImages(query string, args []interface{}) ([]*models.Image, error) {
|
|
var ret models.Images
|
|
if err := qb.query(query, args, &ret); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return []*models.Image(ret), nil
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) galleriesRepository() *joinRepository {
|
|
return &joinRepository{
|
|
repository: repository{
|
|
tx: qb.tx,
|
|
tableName: galleriesImagesTable,
|
|
idColumn: imageIDColumn,
|
|
},
|
|
fkColumn: galleryIDColumn,
|
|
}
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) GetGalleryIDs(imageID int) ([]int, error) {
|
|
return qb.galleriesRepository().getIDs(imageID)
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) UpdateGalleries(imageID int, galleryIDs []int) error {
|
|
// Delete the existing joins and then create new ones
|
|
return qb.galleriesRepository().replace(imageID, galleryIDs)
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) performersRepository() *joinRepository {
|
|
return &joinRepository{
|
|
repository: repository{
|
|
tx: qb.tx,
|
|
tableName: performersImagesTable,
|
|
idColumn: imageIDColumn,
|
|
},
|
|
fkColumn: performerIDColumn,
|
|
}
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) GetPerformerIDs(imageID int) ([]int, error) {
|
|
return qb.performersRepository().getIDs(imageID)
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) UpdatePerformers(imageID int, performerIDs []int) error {
|
|
// Delete the existing joins and then create new ones
|
|
return qb.performersRepository().replace(imageID, performerIDs)
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) tagsRepository() *joinRepository {
|
|
return &joinRepository{
|
|
repository: repository{
|
|
tx: qb.tx,
|
|
tableName: imagesTagsTable,
|
|
idColumn: imageIDColumn,
|
|
},
|
|
fkColumn: tagIDColumn,
|
|
}
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) GetTagIDs(imageID int) ([]int, error) {
|
|
return qb.tagsRepository().getIDs(imageID)
|
|
}
|
|
|
|
func (qb *imageQueryBuilder) UpdateTags(imageID int, tagIDs []int) error {
|
|
// Delete the existing joins and then create new ones
|
|
return qb.tagsRepository().replace(imageID, tagIDs)
|
|
}
|