Compare commits

...

5 Commits

Author SHA1 Message Date
Hein
6ea200bb2b refactor(cors): 🛠️ improve host handling in CORS config
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -27m44s
Build , Vet Test, and Lint / Lint Code (push) Successful in -27m5s
Build , Vet Test, and Lint / Build (push) Successful in -27m29s
Tests / Unit Tests (push) Successful in -27m48s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m22s
Tests / Integration Tests (push) Failing after -28m1s
* Change loop to use index for server instances
* Simplify appending external URLs
* Clean up commented code for clarity
2026-01-06 14:07:56 +02:00
Hein
987244019c feat(cors): enhance CORS configuration with dynamic origins
* Update CORSConfig to allow dynamic origins based on server instances.
* Add ExternalURLs field to ServerInstanceConfig for additional CORS support.
* Implement GetIPs function to retrieve non-local IP addresses for CORS.
2026-01-06 14:05:36 +02:00
Hein
62a8e56f1b feat(reflection): add GetPointerElement function for type handling
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -27m40s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -27m8s
Build , Vet Test, and Lint / Lint Code (push) Successful in -27m5s
Build , Vet Test, and Lint / Build (push) Successful in -27m21s
Tests / Unit Tests (push) Successful in -27m42s
Tests / Integration Tests (push) Failing after -27m55s
* Introduced GetPointerElement to simplify pointer type extraction.
* Updated handleUpdate methods to utilize GetPointerElement for better clarity and maintainability.
2026-01-06 10:45:23 +02:00
Hein
d8df1bdac2 feat(funcspec): add JSON and UUID handling in normalization
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -27m50s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -27m25s
Build , Vet Test, and Lint / Lint Code (push) Successful in -27m22s
Build , Vet Test, and Lint / Build (push) Successful in -27m31s
Tests / Unit Tests (push) Successful in -27m54s
Tests / Integration Tests (push) Failing after -28m3s
* Enhance normalization to support JSON strings as json.RawMessage
* Add support for UUID formatting
* Maintain existing behavior for other types
2026-01-05 17:56:54 +02:00
Hein
c0c669bd3d feat(handler): enhance update logic to merge existing records with incoming data
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -26m28s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -24m52s
Build , Vet Test, and Lint / Lint Code (push) Successful in -26m57s
Build , Vet Test, and Lint / Build (push) Successful in -27m29s
Tests / Integration Tests (push) Failing after -27m58s
Tests / Unit Tests (push) Successful in -26m53s
2026-01-05 12:31:01 +02:00
9 changed files with 366 additions and 89 deletions

View File

@@ -3,6 +3,8 @@ package common
import (
"fmt"
"strings"
"github.com/bitechdev/ResolveSpec/pkg/config"
)
// CORSConfig holds CORS configuration
@@ -15,8 +17,27 @@ type CORSConfig struct {
// DefaultCORSConfig returns a default CORS configuration suitable for HeadSpec
func DefaultCORSConfig() CORSConfig {
configManager := config.GetConfigManager()
cfg, _ := configManager.GetConfig()
hosts := make([]string, 0)
// hosts = append(hosts, "*")
_, _, ipsList := config.GetIPs()
for i := range cfg.Servers.Instances {
server := cfg.Servers.Instances[i]
hosts = append(hosts, fmt.Sprintf("http://%s:%d", server.Host, server.Port))
hosts = append(hosts, fmt.Sprintf("https://%s:%d", server.Host, server.Port))
hosts = append(hosts, fmt.Sprintf("http://%s:%d", "localhost", server.Port))
hosts = append(hosts, server.ExternalURLs...)
for _, ip := range ipsList {
hosts = append(hosts, fmt.Sprintf("http://%s:%d", ip.String(), server.Port))
hosts = append(hosts, fmt.Sprintf("https://%s:%d", ip.String(), server.Port))
}
}
return CORSConfig{
AllowedOrigins: []string{"*"},
AllowedOrigins: hosts,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: GetHeadSpecHeaders(),
MaxAge: 86400, // 24 hours

View File

@@ -73,6 +73,9 @@ type ServerInstanceConfig struct {
// Tags for organization and filtering
Tags map[string]string `mapstructure:"tags"`
// ExternalURLs are additional URLs that this server instance is accessible from (for CORS) for proxy setups
ExternalURLs []string `mapstructure:"external_urls"`
}
// TracingConfig holds OpenTelemetry tracing configuration

View File

@@ -12,6 +12,16 @@ type Manager struct {
v *viper.Viper
}
var configInstance *Manager
// GetConfigManager returns a singleton configuration manager instance
func GetConfigManager() *Manager {
if configInstance == nil {
configInstance = NewManager()
}
return configInstance
}
// NewManager creates a new configuration manager with defaults
func NewManager() *Manager {
v := viper.New()
@@ -32,7 +42,8 @@ func NewManager() *Manager {
// Set default values
setDefaults(v)
return &Manager{v: v}
configInstance = &Manager{v: v}
return configInstance
}
// NewManagerWithOptions creates a new configuration manager with custom options

View File

@@ -2,6 +2,9 @@ package config
import (
"fmt"
"net"
"os"
"strings"
)
// ApplyGlobalDefaults applies global server defaults to this instance
@@ -105,3 +108,42 @@ func (sc *ServersConfig) GetDefault() (*ServerInstanceConfig, error) {
return &instance, nil
}
// GetIPs - GetIP for pc
func GetIPs() (hostname string, ipList string, ipNetList []net.IP) {
defer func() {
if err := recover(); err != nil {
fmt.Println("Recovered in GetIPs", err)
}
}()
hostname, _ = os.Hostname()
ipaddrlist := make([]net.IP, 0)
iplist := ""
addrs, err := net.LookupIP(hostname)
if err != nil {
return hostname, iplist, ipaddrlist
}
for _, a := range addrs {
// cfg.LogInfo("\nFound IP Host Address: %s", a)
if strings.Contains(a.String(), "127.0.0.1") {
continue
}
iplist = fmt.Sprintf("%s,%s", iplist, a)
ipaddrlist = append(ipaddrlist, a)
}
if iplist == "" {
iff, _ := net.InterfaceAddrs()
for _, a := range iff {
// cfg.LogInfo("\nFound IP Address: %s", a)
if strings.Contains(a.String(), "127.0.0.1") {
continue
}
iplist = fmt.Sprintf("%s,%s", iplist, a)
}
}
iplist = strings.TrimLeft(iplist, ",")
return hostname, iplist, ipaddrlist
}

View File

@@ -12,6 +12,8 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/restheadspec"
@@ -123,27 +125,6 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
ComplexAPI: complexAPI,
}
// Execute BeforeQueryList hook
if err := h.hooks.Execute(BeforeQueryList, hookCtx); err != nil {
logger.Error("BeforeQueryList hook failed: %v", err)
sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
}
// Check if hook aborted the operation
if hookCtx.Abort {
if hookCtx.AbortCode == 0 {
hookCtx.AbortCode = http.StatusBadRequest
}
sendError(w, hookCtx.AbortCode, "operation_aborted", hookCtx.AbortMessage, nil)
return
}
// Use potentially modified SQL query and variables from hooks
sqlquery = hookCtx.SQLQuery
variables = hookCtx.Variables
// complexAPI = hookCtx.ComplexAPI
// Extract input variables from SQL query (placeholders like [variable])
sqlquery = h.extractInputVariables(sqlquery, &inputvars)
@@ -203,6 +184,27 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
// Execute query within transaction
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
// Set transaction in hook context for hooks to use
hookCtx.Tx = tx
// Execute BeforeQueryList hook (inside transaction)
if err := h.hooks.Execute(BeforeQueryList, hookCtx); err != nil {
logger.Error("BeforeQueryList hook failed: %v", err)
sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return err
}
// Check if hook aborted the operation
if hookCtx.Abort {
if hookCtx.AbortCode == 0 {
hookCtx.AbortCode = http.StatusBadRequest
}
sendError(w, hookCtx.AbortCode, "operation_aborted", hookCtx.AbortMessage, nil)
return fmt.Errorf("operation aborted: %s", hookCtx.AbortMessage)
}
// Use potentially modified SQL query from hook
sqlquery = hookCtx.SQLQuery
sqlqueryCnt := sqlquery
// Parse sorting and pagination parameters
@@ -286,6 +288,21 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
}
total = hookCtx.Total
// Execute AfterQueryList hook (inside transaction)
hookCtx.Result = dbobjlist
hookCtx.Total = total
hookCtx.Error = nil
if err := h.hooks.Execute(AfterQueryList, hookCtx); err != nil {
logger.Error("AfterQueryList hook failed: %v", err)
sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
return err
}
// Use potentially modified result from hook
if modifiedResult, ok := hookCtx.Result.([]map[string]interface{}); ok {
dbobjlist = modifiedResult
}
total = hookCtx.Total
return nil
})
@@ -294,21 +311,6 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
return
}
// Execute AfterQueryList hook
hookCtx.Result = dbobjlist
hookCtx.Total = total
hookCtx.Error = err
if err := h.hooks.Execute(AfterQueryList, hookCtx); err != nil {
logger.Error("AfterQueryList hook failed: %v", err)
sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
return
}
// Use potentially modified result from hook
if modifiedResult, ok := hookCtx.Result.([]map[string]interface{}); ok {
dbobjlist = modifiedResult
}
total = hookCtx.Total
// Set response headers
respOffset := 0
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
@@ -459,26 +461,6 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
ComplexAPI: complexAPI,
}
// Execute BeforeQuery hook
if err := h.hooks.Execute(BeforeQuery, hookCtx); err != nil {
logger.Error("BeforeQuery hook failed: %v", err)
sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
}
// Check if hook aborted the operation
if hookCtx.Abort {
if hookCtx.AbortCode == 0 {
hookCtx.AbortCode = http.StatusBadRequest
}
sendError(w, hookCtx.AbortCode, "operation_aborted", hookCtx.AbortMessage, nil)
return
}
// Use potentially modified SQL query and variables from hooks
sqlquery = hookCtx.SQLQuery
variables = hookCtx.Variables
// Extract input variables from SQL query
sqlquery = h.extractInputVariables(sqlquery, &inputvars)
@@ -554,6 +536,28 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
// Execute query within transaction
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
// Set transaction in hook context for hooks to use
hookCtx.Tx = tx
// Execute BeforeQuery hook (inside transaction)
if err := h.hooks.Execute(BeforeQuery, hookCtx); err != nil {
logger.Error("BeforeQuery hook failed: %v", err)
sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return err
}
// Check if hook aborted the operation
if hookCtx.Abort {
if hookCtx.AbortCode == 0 {
hookCtx.AbortCode = http.StatusBadRequest
}
sendError(w, hookCtx.AbortCode, "operation_aborted", hookCtx.AbortMessage, nil)
return fmt.Errorf("operation aborted: %s", hookCtx.AbortMessage)
}
// Use potentially modified SQL query from hook
sqlquery = hookCtx.SQLQuery
// Execute BeforeSQLExec hook
if err := h.hooks.Execute(BeforeSQLExec, hookCtx); err != nil {
logger.Error("BeforeSQLExec hook failed: %v", err)
@@ -586,6 +590,19 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
dbobj = modifiedResult
}
// Execute AfterQuery hook (inside transaction)
hookCtx.Result = dbobj
hookCtx.Error = nil
if err := h.hooks.Execute(AfterQuery, hookCtx); err != nil {
logger.Error("AfterQuery hook failed: %v", err)
sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
return err
}
// Use potentially modified result from hook
if modifiedResult, ok := hookCtx.Result.(map[string]interface{}); ok {
dbobj = modifiedResult
}
return nil
})
@@ -594,19 +611,6 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
return
}
// Execute AfterQuery hook
hookCtx.Result = dbobj
hookCtx.Error = err
if err := h.hooks.Execute(AfterQuery, hookCtx); err != nil {
logger.Error("AfterQuery hook failed: %v", err)
sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
return
}
// Use potentially modified result from hook
if modifiedResult, ok := hookCtx.Result.(map[string]interface{}); ok {
dbobj = modifiedResult
}
// Execute BeforeResponse hook
hookCtx.Result = dbobj
if err := h.hooks.Execute(BeforeResponse, hookCtx); err != nil {
@@ -1097,9 +1101,25 @@ func normalizePostgresValue(value interface{}) interface{} {
case map[string]interface{}:
// Recursively normalize nested maps
return normalizePostgresTypes(v)
case string:
var jsonObj interface{}
if err := json.Unmarshal([]byte(v), &jsonObj); err == nil {
// It's valid JSON, return as json.RawMessage so it's not double-encoded
return json.RawMessage(v)
}
return v
case uuid.UUID:
return v.String()
case time.Time:
return v.Format(time.RFC3339)
case bool, int, int8, int16, int32, int64, float32, float64, uint, uint8, uint16, uint32, uint64:
return v
default:
// For other types (int, float, string, bool, etc.), return as-is
// For other types (int, float, bool, etc.), return as-is
// Check stringers
if str, ok := v.(fmt.Stringer); ok {
return str.String()
}
return v
}
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/security"
)
@@ -46,6 +47,10 @@ type HookContext struct {
// User context
UserContext *security.UserContext
// Tx provides access to the database/transaction for executing additional SQL
// This allows hooks to run custom queries in addition to the main Query chain
Tx common.Database
// Pagination and filtering (for list queries)
SortColumns string
Limit int

View File

@@ -47,3 +47,20 @@ func ExtractTableNameOnly(fullName string) string {
return fullName[startIndex:]
}
// GetPointerElement returns the element type if the provided reflect.Type is a pointer.
// If the type is a slice of pointers, it returns the element type of the pointer within the slice.
// If neither condition is met, it returns the original type.
func GetPointerElement(v reflect.Type) reflect.Type {
if v.Kind() == reflect.Ptr {
return v.Elem()
}
if v.Kind() == reflect.Slice && v.Elem().Kind() == reflect.Ptr {
subElem := v.Elem()
if subElem.Elem().Kind() == reflect.Ptr {
return subElem.Elem().Elem()
}
return v.Elem()
}
return v
}

View File

@@ -698,20 +698,83 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
}
// Standard processing without nested relations
query := h.db.NewUpdate().Table(tableName).SetMap(updates)
// Get the primary key name
pkName := reflection.GetPrimaryKeyName(model)
// Apply conditions
// First, read the existing record from the database
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := h.db.NewSelect().Model(existingRecord)
// Apply conditions to select
if urlID != "" {
logger.Debug("Updating by URL ID: %s", urlID)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), urlID)
selectQuery = selectQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), urlID)
} else if reqID != nil {
switch id := reqID.(type) {
case string:
logger.Debug("Updating by request ID: %s", id)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), id)
selectQuery = selectQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
case []string:
logger.Debug("Updating by multiple IDs: %v", id)
query = query.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), id)
if len(id) > 0 {
logger.Debug("Updating by multiple IDs: %v", id)
selectQuery = selectQuery.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(pkName)), id)
}
}
}
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
logger.Warn("No records found to update")
h.sendError(w, http.StatusNotFound, "not_found", "No records found to update", nil)
return
}
logger.Error("Error fetching existing record: %v", err)
h.sendError(w, http.StatusInternalServerError, "update_error", "Error fetching existing record", err)
return
}
// Convert existing record to map
existingMap := make(map[string]interface{})
jsonData, err := json.Marshal(existingRecord)
if err != nil {
logger.Error("Error marshaling existing record: %v", err)
h.sendError(w, http.StatusInternalServerError, "update_error", "Error processing existing record", err)
return
}
if err := json.Unmarshal(jsonData, &existingMap); err != nil {
logger.Error("Error unmarshaling existing record: %v", err)
h.sendError(w, http.StatusInternalServerError, "update_error", "Error processing existing record", err)
return
}
// 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
}
// Build update query with merged data
query := h.db.NewUpdate().Table(tableName).SetMap(existingMap)
// Apply conditions
if urlID != "" {
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), urlID)
} else if reqID != nil {
switch id := reqID.(type) {
case string:
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
case []string:
query = query.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(pkName)), id)
}
}
@@ -782,11 +845,42 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
}
// Standard batch update without nested relations
pkName := reflection.GetPrimaryKeyName(model)
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range updates {
if itemID, ok := item["id"]; ok {
// First, read the existing record
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := tx.NewSelect().Model(existingRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
continue // Skip if record not found
}
return fmt.Errorf("failed to fetch existing record: %w", err)
}
txQuery := tx.NewUpdate().Table(tableName).SetMap(item).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
// Convert existing record to map
existingMap := make(map[string]interface{})
jsonData, err := json.Marshal(existingRecord)
if err != nil {
return fmt.Errorf("failed to marshal existing record: %w", err)
}
if err := json.Unmarshal(jsonData, &existingMap); err != nil {
return fmt.Errorf("failed to unmarshal existing record: %w", err)
}
// 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
}
txQuery := tx.NewUpdate().Table(tableName).SetMap(existingMap).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
if _, err := txQuery.Exec(ctx); err != nil {
return err
}
@@ -857,13 +951,44 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
}
// Standard batch update without nested relations
pkName := reflection.GetPrimaryKeyName(model)
list := make([]interface{}, 0)
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range updates {
if itemMap, ok := item.(map[string]interface{}); ok {
if itemID, ok := itemMap["id"]; ok {
// First, read the existing record
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := tx.NewSelect().Model(existingRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
continue // Skip if record not found
}
return fmt.Errorf("failed to fetch existing record: %w", err)
}
txQuery := tx.NewUpdate().Table(tableName).SetMap(itemMap).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
// Convert existing record to map
existingMap := make(map[string]interface{})
jsonData, err := json.Marshal(existingRecord)
if err != nil {
return fmt.Errorf("failed to marshal existing record: %w", err)
}
if err := json.Unmarshal(jsonData, &existingMap); err != nil {
return fmt.Errorf("failed to unmarshal existing record: %w", err)
}
// 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
}
txQuery := tx.NewUpdate().Table(tableName).SetMap(existingMap).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
if _, err := txQuery.Exec(ctx); err != nil {
return err
}

View File

@@ -1239,6 +1239,26 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// Create temporary nested processor with transaction
txNestedProcessor := common.NewNestedCUDProcessor(tx, h.registry, h)
// First, read the existing record from the database
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := tx.NewSelect().Model(existingRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("record not found with ID: %v", targetID)
}
return fmt.Errorf("failed to fetch existing record: %w", err)
}
// Convert existing record to map
existingMap := make(map[string]interface{})
jsonData, err := json.Marshal(existingRecord)
if err != nil {
return fmt.Errorf("failed to marshal existing record: %w", err)
}
if err := json.Unmarshal(jsonData, &existingMap); err != nil {
return fmt.Errorf("failed to unmarshal existing record: %w", err)
}
// Extract nested relations if present (but don't process them yet)
var nestedRelations map[string]interface{}
if h.shouldUseNestedProcessor(dataMap, model) {
@@ -1251,15 +1271,30 @@ 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
}
// Ensure ID is in the data map for the update
dataMap[pkName] = targetID
existingMap[pkName] = targetID
dataMap = existingMap
// Populate model instance from dataMap to preserve custom types (like SqlJSONB)
// Get the type of the model, handling both pointer and non-pointer types
modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Ptr {
modelType = modelType.Elem()
}
modelType = reflection.GetPointerElement(modelType)
modelInstance := reflect.New(modelType).Interface()
if err := reflection.MapToStruct(dataMap, modelInstance); err != nil {
return fmt.Errorf("failed to populate model from data: %w", err)
@@ -1297,7 +1332,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// Fetch the updated record to return the new values
modelValue := reflect.New(reflect.TypeOf(model)).Interface()
selectQuery := tx.NewSelect().Model(modelValue).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
selectQuery = tx.NewSelect().Model(modelValue).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
if err := selectQuery.ScanModel(ctx); err != nil {
return fmt.Errorf("failed to fetch updated record: %w", err)
}
@@ -1563,9 +1598,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
// First, fetch the record that will be deleted
modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Ptr {
modelType = modelType.Elem()
}
modelType = reflection.GetPointerElement(modelType)
recordToDelete := reflect.New(modelType).Interface()
selectQuery := h.db.NewSelect().Model(recordToDelete).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)