Compare commits

...
2 Commits
Author SHA1 Message Date
Hein 2898b335f8 feat(db): add ApplicationName to connection configuration
* Introduced ApplicationName field to identify clients in DSN
* Set default ApplicationName to "ResolveSpec"
* Updated tests for ApplicationName handling in DSN
2026-09-30 17:12:04 +02:00
Hein 20ba8ed112 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
2026-09-30 17:03:42 +02:00
12 changed files with 210 additions and 80 deletions
+23
View File
@@ -0,0 +1,23 @@
package common
// MergeUpdateValues merges the incoming request values into the existing
// record map (in place) and returns it.
//
// Every key present in incoming overwrites the existing value, including empty
// strings and explicit nulls, so clients can clear a column by sending "" or
// null. Keys absent from incoming are left untouched.
//
// When disallowNulls is true, nil values are skipped and the existing value is
// kept. Empty strings are still applied.
func MergeUpdateValues(existing, incoming map[string]interface{}, disallowNulls bool) map[string]interface{} {
if existing == nil {
existing = make(map[string]interface{}, len(incoming))
}
for key, newValue := range incoming {
if newValue == nil && disallowNulls {
continue
}
existing[key] = newValue
}
return existing
}
+29
View File
@@ -0,0 +1,29 @@
package common
import "testing"
func TestMergeUpdateValues(t *testing.T) {
newExisting := func() map[string]interface{} {
return map[string]interface{}{"name": "old", "note": "keep", "other": "x"}
}
incoming := map[string]interface{}{"name": "", "note": nil}
got := MergeUpdateValues(newExisting(), incoming, false)
if got["name"] != "" {
t.Errorf("name = %v, want empty string", got["name"])
}
if v, ok := got["note"]; !ok || v != nil {
t.Errorf("note = %v (present=%v), want nil", v, ok)
}
if got["other"] != "x" {
t.Errorf("absent key changed: %v", got["other"])
}
got = MergeUpdateValues(newExisting(), incoming, true)
if got["name"] != "" {
t.Errorf("disallowNulls: name = %v, want empty string", got["name"])
}
if got["note"] != "keep" {
t.Errorf("disallowNulls: note = %v, want keep", got["note"])
}
}
+4
View File
@@ -55,6 +55,10 @@ type DBConnectionConfig struct {
SSLMode string `mapstructure:"sslmode"` // disable, require, verify-ca, verify-full
Schema string `mapstructure:"schema"` // Default schema
// ApplicationName identifies this client to the server (postgres
// application_name, mssql app name, mongodb appName)
ApplicationName string `mapstructure:"application_name"`
// SQLite specific
FilePath string `mapstructure:"filepath"`
+1
View File
@@ -271,6 +271,7 @@ db, _ := mgr.GetDefaultDatabase()
| `database` | string | Database name |
| `sslmode` | string | SSL mode (postgres/mssql): `disable`, `require`, etc. |
| `schema` | string | Default schema (postgres/mssql) |
| `application_name` | string | Client name shown by the server (postgres `application_name`, mssql `app name`, mongodb `appName`); defaults to `ResolveSpec` |
| `filepath` | string | File path (sqlite only) |
| `auth_source` | string | Auth source (mongodb) |
| `replica_set` | string | Replica set name (mongodb) |
+22
View File
@@ -72,6 +72,9 @@ type ManagerConfig struct {
EnableAutoReconnect bool `mapstructure:"enable_auto_reconnect"`
}
// DefaultApplicationName is used when a connection does not set ApplicationName.
const DefaultApplicationName = "ResolveSpec"
// ConnectionConfig defines configuration for a single database connection
type ConnectionConfig struct {
// Name is the unique name of this connection
@@ -95,6 +98,10 @@ type ConnectionConfig struct {
SSLMode string `mapstructure:"sslmode"` // disable, require, verify-ca, verify-full
Schema string `mapstructure:"schema"` // Default schema
// ApplicationName identifies this client to the server (postgres
// application_name, mssql app name, mongodb appName)
ApplicationName string `mapstructure:"application_name"`
// SQLite specific
FilePath string `mapstructure:"filepath"`
@@ -225,6 +232,10 @@ func (cc *ConnectionConfig) ApplyDefaults(global *ManagerConfig) {
cc.ConnMaxIdleTime = &idleTime
}
if cc.ApplicationName == "" {
cc.ApplicationName = DefaultApplicationName
}
// Default timeouts
if cc.ConnectTimeout == 0 {
cc.ConnectTimeout = 10 * time.Second
@@ -347,6 +358,9 @@ func (cc *ConnectionConfig) buildPostgresDSN() string {
if cc.Schema != "" {
q.Set("search_path", cc.Schema)
}
if cc.ApplicationName != "" {
q.Set("application_name", cc.ApplicationName)
}
u := url.URL{
Scheme: "postgres",
@@ -405,6 +419,9 @@ func (cc *ConnectionConfig) buildMSSQLDSN() string {
if cc.Schema != "" {
q.Set("schema", cc.Schema)
}
if cc.ApplicationName != "" {
q.Set("app name", cc.ApplicationName)
}
if cc.ConnectTimeout > 0 {
sec := strconv.Itoa(int(cc.ConnectTimeout.Seconds()))
q.Set("connection timeout", sec)
@@ -437,6 +454,9 @@ func (cc *ConnectionConfig) buildMongoDSN() string {
if cc.ReadPreference != "" {
q.Set("readPreference", cc.ReadPreference)
}
if cc.ApplicationName != "" {
q.Set("appName", cc.ApplicationName)
}
u := url.URL{
Scheme: "mongodb",
@@ -480,6 +500,7 @@ func FromConfig(cfg config.DBManagerConfig) ManagerConfig {
Database: connCfg.Database,
SSLMode: connCfg.SSLMode,
Schema: connCfg.Schema,
ApplicationName: connCfg.ApplicationName,
FilePath: connCfg.FilePath,
AuthSource: connCfg.AuthSource,
ReplicaSet: connCfg.ReplicaSet,
@@ -519,6 +540,7 @@ func (cc *ConnectionConfig) GetConnMaxIdleTime() *time.Duration { return cc.Conn
func (cc *ConnectionConfig) GetQueryTimeout() time.Duration { return cc.QueryTimeout }
func (cc *ConnectionConfig) GetEnableMetrics() bool { return cc.EnableMetrics }
func (cc *ConnectionConfig) GetReadPreference() string { return cc.ReadPreference }
func (cc *ConnectionConfig) GetApplicationName() string { return cc.ApplicationName }
func (cc *ConnectionConfig) GetRetryAttempts() int { return cc.RetryAttempts }
func (cc *ConnectionConfig) GetRetryDelay() time.Duration { return cc.RetryDelay }
func (cc *ConnectionConfig) GetRetryMaxDelay() time.Duration { return cc.RetryMaxDelay }
+30
View File
@@ -107,3 +107,33 @@ func TestSQLiteMemoryPoolPinned(t *testing.T) {
}
}
}
func TestApplicationNameInDSN(t *testing.T) {
cc := ConnectionConfig{Host: "h", Port: 1, Database: "d", ApplicationName: "my app&x=y"}
for name, c := range map[string]struct{ dsn, key string }{
"postgres": {cc.buildPostgresDSN(), "application_name"},
"mssql": {cc.buildMSSQLDSN(), "app name"},
"mongo": {cc.buildMongoDSN(), "appName"},
} {
u, err := url.Parse(c.dsn)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if got := u.Query().Get(c.key); got != cc.ApplicationName {
t.Errorf("%s: %s = %q, want %q", name, c.key, got, cc.ApplicationName)
}
}
}
func TestApplicationNameDefault(t *testing.T) {
cc := ConnectionConfig{Type: DatabaseTypePostgreSQL, Host: "h", Database: "d"}
cc.ApplyDefaults(nil)
if cc.ApplicationName != "ResolveSpec" {
t.Errorf("ApplicationName = %q, want ResolveSpec", cc.ApplicationName)
}
cc = ConnectionConfig{Type: DatabaseTypePostgreSQL, Host: "h", Database: "d", ApplicationName: "x"}
cc.ApplyDefaults(nil)
if cc.ApplicationName != "x" {
t.Errorf("explicit ApplicationName overwritten: %q", cc.ApplicationName)
}
}
+6
View File
@@ -123,6 +123,12 @@ func buildPGXConfig(cfg ConnectionConfig) (*pgx.ConnConfig, error) {
}
cc.DialFunc = newDialFunc(cfg.GetConnectTimeout())
// Also applies to caller-supplied DSNs that do not set application_name.
if name := cfg.GetApplicationName(); name != "" {
if _, set := cc.RuntimeParams["application_name"]; !set {
cc.RuntimeParams["application_name"] = name
}
}
if cfg.GetQueryTimeout() > 0 {
if _, set := cc.RuntimeParams["statement_timeout"]; !set {
cc.RuntimeParams["statement_timeout"] = fmt.Sprintf("%d", cfg.GetQueryTimeout().Milliseconds())
+1
View File
@@ -59,6 +59,7 @@ type ConnectionConfig interface {
GetConnMaxLifetime() *time.Duration
GetConnMaxIdleTime() *time.Duration
GetReadPreference() string
GetApplicationName() string
GetRetryAttempts() int
GetRetryDelay() time.Duration
GetRetryMaxDelay() time.Duration
+34 -15
View File
@@ -45,6 +45,9 @@ type Handler struct {
// Started flag
started bool
mu sync.RWMutex
// disallowNulls skips null values in update payloads instead of applying them
disallowNulls bool
}
// NewHandler creates a new MQTT handler
@@ -812,6 +815,14 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
}
// create creates a new record
// 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)
@@ -840,25 +851,33 @@ func (h *Handler) create(hookCtx *HookContext) (interface{}, error) {
// update updates an existing record
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
+15 -35
View File
@@ -32,6 +32,7 @@ type Handler struct {
fallbackHandler FallbackHandler
openAPIGenerator func() (string, error)
defaultSort map[string][]common.SortOption
disallowNulls bool
}
// NewHandler creates a new API handler with database and registry abstractions
@@ -52,6 +53,14 @@ func (h *Handler) Hooks() *HookRegistry {
return h.hooks
}
// 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
}
// SetFallbackHandler sets a fallback handler to be called when no model is found
// If not set, the handler will simply return (pass through to next route)
func (h *Handler) SetFallbackHandler(fallback FallbackHandler) {
@@ -1236,21 +1245,8 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
return fmt.Errorf("error unmarshaling existing record: %w", err)
}
// Merge only non-null and non-empty values from the incoming request into the existing record
for key, newValue := range updates {
// Skip if the value is nil
if newValue == nil {
continue
}
// Skip if the value is an empty string
if strVal, ok := newValue.(string); ok && strVal == "" {
continue
}
// Update the existing map with the new value
existingMap[key] = newValue
}
// Overwrite with every key present in the request (including "" and null unless disallowed)
common.MergeUpdateValues(existingMap, updates, h.disallowNulls)
// Build update query with merged data
query := tx.NewUpdate().Table(tableName).SetMap(existingMap)
@@ -1421,16 +1417,8 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
item = modifiedData
}
// Merge only non-null and non-empty values
for key, newValue := range item {
if newValue == nil {
continue
}
if strVal, ok := newValue.(string); ok && strVal == "" {
continue
}
existingMap[key] = newValue
}
// Overwrite with every key present in the request (including "" and null unless disallowed)
common.MergeUpdateValues(existingMap, item, h.disallowNulls)
txQuery := tx.NewUpdate().Table(tableName).SetMap(existingMap).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
if _, err := txQuery.Exec(ctx); err != nil {
@@ -1578,16 +1566,8 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
itemMap = modifiedData
}
// Merge only non-null and non-empty values
for key, newValue := range itemMap {
if newValue == nil {
continue
}
if strVal, ok := newValue.(string); ok && strVal == "" {
continue
}
existingMap[key] = newValue
}
// Overwrite with every key present in the request (including "" and null unless disallowed)
common.MergeUpdateValues(existingMap, itemMap, h.disallowNulls)
txQuery := tx.NewUpdate().Table(tableName).SetMap(existingMap).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
if _, err := txQuery.Exec(ctx); err != nil {
+11 -15
View File
@@ -33,6 +33,7 @@ type Handler struct {
fallbackHandler FallbackHandler
openAPIGenerator func() (string, error)
defaultSort map[string][]common.SortOption
disallowNulls bool
}
// NewHandler creates a new API handler with database and registry abstractions
@@ -59,6 +60,14 @@ func (h *Handler) Hooks() *HookRegistry {
return h.hooks
}
// 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
}
// SetFallbackHandler sets a fallback handler to be called when no model is found
// If not set, the handler will simply return (pass through to next route)
func (h *Handler) SetFallbackHandler(fallback FallbackHandler) {
@@ -1597,21 +1606,8 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
nestedRelations = relations
}
// Merge only non-null and non-empty values from the incoming request into the existing record
for key, newValue := range dataMap {
// Skip if the value is nil
if newValue == nil {
continue
}
// Skip if the value is an empty string
if strVal, ok := newValue.(string); ok && strVal == "" {
continue
}
// Update the existing map with the new value
existingMap[key] = newValue
}
// Overwrite with every key present in the request (including "" and null unless disallowed)
common.MergeUpdateValues(existingMap, dataMap, h.disallowNulls)
// Ensure ID is in the data map for the update
existingMap[pkName] = targetID
+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