feat(update): allow clearing values with "" and null on update

Update handlers skipped empty strings and nulls, so a client could not
blank or null out a column. Every key present in the payload now
overwrites the stored value, including "" and null.

- add common.MergeUpdateValues and use it in resolvespec and restheadspec
- add Handler.SetDisallowNulls to skip null values (""still overwrites)
- websocketspec and mqttspec now write only the keys present in the
  payload via SetMap instead of updating the whole zeroed model, which
  clobbered absent fields
This commit is contained in:
Hein
2026-09-30 17:03:42 +02:00
parent 1214f69e0c
commit 20ba8ed112
6 changed files with 146 additions and 80 deletions
+34 -15
View File
@@ -38,6 +38,9 @@ type Handler struct {
subscriptionManager *SubscriptionManager
upgrader websocket.Upgrader
ctx context.Context
// disallowNulls skips null values in update payloads instead of applying them
disallowNulls bool
}
// NewHandler creates a new WebSocket handler
@@ -682,6 +685,14 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
return hookCtx.ModelPtr, metadata, nil
}
// SetDisallowNulls controls whether explicit null values in update payloads are
// ignored. By default a key present in the payload overwrites the stored value,
// including "" and null. When true, null values are skipped and the existing
// value is kept ("" still overwrites).
func (h *Handler) SetDisallowNulls(disallow bool) {
h.disallowNulls = disallow
}
func (h *Handler) create(hookCtx *HookContext) (interface{}, error) {
// Marshal and unmarshal data into model
dataBytes, err := json.Marshal(hookCtx.Data)
@@ -709,25 +720,33 @@ func (h *Handler) create(hookCtx *HookContext) (interface{}, error) {
}
func (h *Handler) update(hookCtx *HookContext) (interface{}, error) {
// Marshal and unmarshal data into model
dataBytes, err := json.Marshal(hookCtx.Data)
if err != nil {
return nil, fmt.Errorf("failed to marshal data: %w", err)
// Convert request data to a map
var updates map[string]interface{}
if m, ok := hookCtx.Data.(map[string]interface{}); ok {
updates = m
} else {
dataBytes, err := json.Marshal(hookCtx.Data)
if err != nil {
return nil, fmt.Errorf("failed to marshal data: %w", err)
}
if err := json.Unmarshal(dataBytes, &updates); err != nil {
return nil, fmt.Errorf("failed to unmarshal data into map: %w", err)
}
}
if err := json.Unmarshal(dataBytes, hookCtx.ModelPtr); err != nil {
return nil, fmt.Errorf("failed to unmarshal data into model: %w", err)
}
// Update record
query := h.db.NewUpdate().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
// Add ID filter
pkName := reflection.GetPrimaryKeyName(hookCtx.Model)
query = query.Where(fmt.Sprintf("%s = ?", pkName), hookCtx.ID)
if _, err := query.Exec(hookCtx.Context); err != nil {
return nil, fmt.Errorf("failed to update record: %w", err)
// Only the keys present in the request are written. "" and null overwrite
// the stored value unless disallowNulls is set, in which case null is skipped.
values := common.MergeUpdateValues(make(map[string]interface{}, len(updates)), updates, h.disallowNulls)
if len(values) > 0 {
query := h.db.NewUpdate().Table(hookCtx.TableName).SetMap(values).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), hookCtx.ID)
if _, err := query.Exec(hookCtx.Context); err != nil {
return nil, fmt.Errorf("failed to update record: %w", err)
}
}
// Fetch updated record