From 2c86639c718f82e66251ae31b2a8aeb57c5eeed2 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:00:14 +1100 Subject: [PATCH] Add support for removing custom field keys --- graphql/schema/types/metadata.graphql | 2 + internal/api/custom_fields.go | 12 ++++++ internal/api/resolver_mutation_performer.go | 9 ++-- pkg/models/custom_fields.go | 2 + pkg/sqlite/custom_fields.go | 46 ++++++++++++++++++--- pkg/sqlite/custom_fields_test.go | 38 ++++++++++++++++- 6 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 internal/api/custom_fields.go diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 923c25b4c..c01858f64 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -344,4 +344,6 @@ input CustomFieldsInput { full: Map "If populated, only the keys in this map will be updated" partial: Map + "Remove any keys in this list" + remove: [String!] } diff --git a/internal/api/custom_fields.go b/internal/api/custom_fields.go new file mode 100644 index 000000000..5eaa6f67a --- /dev/null +++ b/internal/api/custom_fields.go @@ -0,0 +1,12 @@ +package api + +import "github.com/stashapp/stash/pkg/models" + +func handleUpdateCustomFields(input models.CustomFieldsInput) models.CustomFieldsInput { + ret := input + // convert json.Numbers to int/float + ret.Full = convertMapJSONNumbers(ret.Full) + ret.Partial = convertMapJSONNumbers(ret.Partial) + + return ret +} diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index 15fb5056a..c54e3ca93 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -297,10 +297,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per return nil, fmt.Errorf("converting tag ids: %w", err) } - updatedPerformer.CustomFields = input.CustomFields - // convert json.Numbers to int/float - updatedPerformer.CustomFields.Full = convertMapJSONNumbers(updatedPerformer.CustomFields.Full) - updatedPerformer.CustomFields.Partial = convertMapJSONNumbers(updatedPerformer.CustomFields.Partial) + updatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields) var imageData []byte imageIncluded := translator.hasField("image") @@ -417,6 +414,10 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe return nil, fmt.Errorf("converting tag ids: %w", err) } + if input.CustomFields != nil { + updatedPerformer.CustomFields = handleUpdateCustomFields(*input.CustomFields) + } + ret := []*models.Performer{} // Start the transaction and save the performers diff --git a/pkg/models/custom_fields.go b/pkg/models/custom_fields.go index 977c2fe89..5c3acd18b 100644 --- a/pkg/models/custom_fields.go +++ b/pkg/models/custom_fields.go @@ -9,6 +9,8 @@ type CustomFieldsInput struct { Full map[string]interface{} `json:"full"` // If populated, only the keys in this map will be updated Partial map[string]interface{} `json:"partial"` + // Remove any keys in this list + Remove []string `json:"remove"` } type CustomFieldsReader interface { diff --git a/pkg/sqlite/custom_fields.go b/pkg/sqlite/custom_fields.go index bac6ae5e1..63f85b250 100644 --- a/pkg/sqlite/custom_fields.go +++ b/pkg/sqlite/custom_fields.go @@ -41,18 +41,31 @@ func (s *customFieldsStore) SetCustomFields(ctx context.Context, id int, values case values.Partial != nil: partial = true valMap = values.Partial - default: - return nil } - if err := s.validateCustomFields(valMap); err != nil { + if valMap != nil { + if err := s.validateCustomFields(valMap, values.Remove); err != nil { + return err + } + + if err := s.setCustomFields(ctx, id, valMap, partial); err != nil { + return err + } + } + + if err := s.deleteCustomFields(ctx, id, values.Remove); err != nil { return err } - return s.setCustomFields(ctx, id, valMap, partial) + return nil } -func (s *customFieldsStore) validateCustomFields(values map[string]interface{}) error { +func (s *customFieldsStore) validateCustomFields(values map[string]interface{}, deleteKeys []string) error { + // if values is nil, nothing to validate + if values == nil { + return nil + } + // ensure that custom field names are valid // no leading or trailing whitespace, no empty strings for k := range values { @@ -61,6 +74,13 @@ func (s *customFieldsStore) validateCustomFields(values map[string]interface{}) } } + // ensure delete keys are not also in values + for _, k := range deleteKeys { + if _, ok := values[k]; ok { + return fmt.Errorf("custom field name %q cannot be in both values and delete keys", k) + } + } + return nil } @@ -130,6 +150,22 @@ func (s *customFieldsStore) setCustomFields(ctx context.Context, id int, values return nil } +func (s *customFieldsStore) deleteCustomFields(ctx context.Context, id int, keys []string) error { + if len(keys) == 0 { + return nil + } + + q := dialect.Delete(s.table). + Where(s.fk.Eq(id)). + Where(goqu.I("field").In(keys)) + + if _, err := exec(ctx, q); err != nil { + return fmt.Errorf("deleting custom fields: %w", err) + } + + return nil +} + func (s *customFieldsStore) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { q := dialect.Select("field", "value").From(s.table).Where(s.fk.Eq(id)) diff --git a/pkg/sqlite/custom_fields_test.go b/pkg/sqlite/custom_fields_test.go index ce5c77487..8ee154aec 100644 --- a/pkg/sqlite/custom_fields_test.go +++ b/pkg/sqlite/custom_fields_test.go @@ -64,6 +64,18 @@ func TestSetCustomFields(t *testing.T) { }), false, }, + { + "valid remove", + models.CustomFieldsInput{ + Remove: []string{"real"}, + }, + func() map[string]interface{} { + m := getPerformerCustomFields(performerIdx) + delete(m, "real") + return m + }(), + false, + }, { "leading space full", models.CustomFieldsInput{ @@ -144,16 +156,38 @@ func TestSetCustomFields(t *testing.T) { nil, true, }, + { + "invalid remove full", + models.CustomFieldsInput{ + Full: map[string]interface{}{ + "key": "value", + }, + Remove: []string{"key"}, + }, + nil, + true, + }, + { + "invalid remove partial", + models.CustomFieldsInput{ + Partial: map[string]interface{}{ + "real": float64(4.56), + }, + Remove: []string{"real"}, + }, + nil, + true, + }, } // use performer custom fields store store := db.Performer id := performerIDs[performerIdx] - assert := assert.New(t) - for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + err := store.SetCustomFields(ctx, id, tt.input) if (err != nil) != tt.wantErr { t.Errorf("SetCustomFields() error = %v, wantErr %v", err, tt.wantErr)