Compare commits

...

7 Commits

Author SHA1 Message Date
Hein
e220ab3d34 refactor(reflection): 🛠️ comment out ToSnakeCase usage in MapToStruct 2026-01-07 10:23:37 +02:00
Hein
6a0297713a feat(reflection): enhance ToSnakeCase and add convertSlice function
* Improve ToSnakeCase to handle consecutive uppercase letters.
* Introduce convertSlice for element-wise conversions between slices.
* Update setFieldValue to support new slice conversion logic.
2026-01-07 10:23:23 +02:00
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
10 changed files with 473 additions and 93 deletions

View File

@@ -3,6 +3,8 @@ package common
import ( import (
"fmt" "fmt"
"strings" "strings"
"github.com/bitechdev/ResolveSpec/pkg/config"
) )
// CORSConfig holds CORS configuration // CORSConfig holds CORS configuration
@@ -15,8 +17,27 @@ type CORSConfig struct {
// DefaultCORSConfig returns a default CORS configuration suitable for HeadSpec // DefaultCORSConfig returns a default CORS configuration suitable for HeadSpec
func DefaultCORSConfig() CORSConfig { 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{ return CORSConfig{
AllowedOrigins: []string{"*"}, AllowedOrigins: hosts,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: GetHeadSpecHeaders(), AllowedHeaders: GetHeadSpecHeaders(),
MaxAge: 86400, // 24 hours MaxAge: 86400, // 24 hours

View File

@@ -73,6 +73,9 @@ type ServerInstanceConfig struct {
// Tags for organization and filtering // Tags for organization and filtering
Tags map[string]string `mapstructure:"tags"` 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 // TracingConfig holds OpenTelemetry tracing configuration

View File

@@ -12,6 +12,16 @@ type Manager struct {
v *viper.Viper 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 // NewManager creates a new configuration manager with defaults
func NewManager() *Manager { func NewManager() *Manager {
v := viper.New() v := viper.New()
@@ -32,7 +42,8 @@ func NewManager() *Manager {
// Set default values // Set default values
setDefaults(v) setDefaults(v)
return &Manager{v: v} configInstance = &Manager{v: v}
return configInstance
} }
// NewManagerWithOptions creates a new configuration manager with custom options // NewManagerWithOptions creates a new configuration manager with custom options

View File

@@ -2,6 +2,9 @@ package config
import ( import (
"fmt" "fmt"
"net"
"os"
"strings"
) )
// ApplyGlobalDefaults applies global server defaults to this instance // ApplyGlobalDefaults applies global server defaults to this instance
@@ -105,3 +108,42 @@ func (sc *ServersConfig) GetDefault() (*ServerInstanceConfig, error) {
return &instance, nil 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" "strings"
"time" "time"
"github.com/google/uuid"
"github.com/bitechdev/ResolveSpec/pkg/common" "github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger" "github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/restheadspec" "github.com/bitechdev/ResolveSpec/pkg/restheadspec"
@@ -123,27 +125,6 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
ComplexAPI: complexAPI, 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]) // Extract input variables from SQL query (placeholders like [variable])
sqlquery = h.extractInputVariables(sqlquery, &inputvars) sqlquery = h.extractInputVariables(sqlquery, &inputvars)
@@ -203,6 +184,27 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
// Execute query within transaction // Execute query within transaction
err := h.db.RunInTransaction(ctx, func(tx common.Database) error { 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 sqlqueryCnt := sqlquery
// Parse sorting and pagination parameters // Parse sorting and pagination parameters
@@ -286,6 +288,21 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
} }
total = hookCtx.Total 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 return nil
}) })
@@ -294,21 +311,6 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
return 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 // Set response headers
respOffset := 0 respOffset := 0
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" { if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
@@ -459,26 +461,6 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
ComplexAPI: complexAPI, 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 // Extract input variables from SQL query
sqlquery = h.extractInputVariables(sqlquery, &inputvars) sqlquery = h.extractInputVariables(sqlquery, &inputvars)
@@ -554,6 +536,28 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
// Execute query within transaction // Execute query within transaction
err := h.db.RunInTransaction(ctx, func(tx common.Database) error { 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 // Execute BeforeSQLExec hook
if err := h.hooks.Execute(BeforeSQLExec, hookCtx); err != nil { if err := h.hooks.Execute(BeforeSQLExec, hookCtx); err != nil {
logger.Error("BeforeSQLExec hook failed: %v", err) logger.Error("BeforeSQLExec hook failed: %v", err)
@@ -586,6 +590,19 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
dbobj = modifiedResult 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 return nil
}) })
@@ -594,19 +611,6 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
return 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 // Execute BeforeResponse hook
hookCtx.Result = dbobj hookCtx.Result = dbobj
if err := h.hooks.Execute(BeforeResponse, hookCtx); err != nil { if err := h.hooks.Execute(BeforeResponse, hookCtx); err != nil {
@@ -1097,9 +1101,25 @@ func normalizePostgresValue(value interface{}) interface{} {
case map[string]interface{}: case map[string]interface{}:
// Recursively normalize nested maps // Recursively normalize nested maps
return normalizePostgresTypes(v) 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: 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 return v
} }
} }

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger" "github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/security" "github.com/bitechdev/ResolveSpec/pkg/security"
) )
@@ -46,6 +47,10 @@ type HookContext struct {
// User context // User context
UserContext *security.UserContext 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) // Pagination and filtering (for list queries)
SortColumns string SortColumns string
Limit int Limit int

View File

@@ -47,3 +47,20 @@ func ExtractTableNameOnly(fullName string) string {
return fullName[startIndex:] 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

@@ -584,11 +584,23 @@ func ExtractSourceColumn(colName string) string {
} }
// ToSnakeCase converts a string from CamelCase to snake_case // ToSnakeCase converts a string from CamelCase to snake_case
// Handles consecutive uppercase letters (acronyms) correctly:
// "HTTPServer" -> "http_server", "UserID" -> "user_id", "MyHTTPServer" -> "my_http_server"
func ToSnakeCase(s string) string { func ToSnakeCase(s string) string {
var result strings.Builder var result strings.Builder
for i, r := range s { runes := []rune(s)
for i, r := range runes {
if i > 0 && r >= 'A' && r <= 'Z' { if i > 0 && r >= 'A' && r <= 'Z' {
result.WriteRune('_') // Add underscore if:
// 1. Previous character is lowercase, OR
// 2. Next character is lowercase (transition from acronym to word)
prevIsLower := runes[i-1] >= 'a' && runes[i-1] <= 'z'
nextIsLower := i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z'
if prevIsLower || nextIsLower {
result.WriteRune('_')
}
} }
result.WriteRune(r) result.WriteRune(r)
} }
@@ -961,7 +973,7 @@ func MapToStruct(dataMap map[string]interface{}, target interface{}) error {
// 4. Field name variations // 4. Field name variations
columnNames = append(columnNames, field.Name) columnNames = append(columnNames, field.Name)
columnNames = append(columnNames, strings.ToLower(field.Name)) columnNames = append(columnNames, strings.ToLower(field.Name))
columnNames = append(columnNames, ToSnakeCase(field.Name)) // columnNames = append(columnNames, ToSnakeCase(field.Name))
// Map all column name variations to this field index // Map all column name variations to this field index
for _, colName := range columnNames { for _, colName := range columnNames {
@@ -1067,7 +1079,7 @@ func setFieldValue(field reflect.Value, value interface{}) error {
case string: case string:
field.SetBytes([]byte(v)) field.SetBytes([]byte(v))
return nil return nil
case map[string]interface{}, []interface{}: case map[string]interface{}, []interface{}, []*any, map[string]*any:
// Marshal complex types to JSON for SqlJSONB fields // Marshal complex types to JSON for SqlJSONB fields
jsonBytes, err := json.Marshal(v) jsonBytes, err := json.Marshal(v)
if err != nil { if err != nil {
@@ -1077,6 +1089,11 @@ func setFieldValue(field reflect.Value, value interface{}) error {
return nil return nil
} }
} }
// Handle slice-to-slice conversions (e.g., []interface{} to []*SomeModel)
if valueReflect.Kind() == reflect.Slice {
return convertSlice(field, valueReflect)
}
} }
// Handle struct types (like SqlTimeStamp, SqlDate, SqlTime which wrap SqlNull[time.Time]) // Handle struct types (like SqlTimeStamp, SqlDate, SqlTime which wrap SqlNull[time.Time])
@@ -1156,6 +1173,92 @@ func setFieldValue(field reflect.Value, value interface{}) error {
return fmt.Errorf("cannot convert %v to %v", valueReflect.Type(), field.Type()) return fmt.Errorf("cannot convert %v to %v", valueReflect.Type(), field.Type())
} }
// convertSlice converts a source slice to a target slice type, handling element-wise conversions
// Supports converting []interface{} to slices of structs or pointers to structs
func convertSlice(targetSlice reflect.Value, sourceSlice reflect.Value) error {
if sourceSlice.Kind() != reflect.Slice || targetSlice.Kind() != reflect.Slice {
return fmt.Errorf("both source and target must be slices")
}
// Get the element type of the target slice
targetElemType := targetSlice.Type().Elem()
sourceLen := sourceSlice.Len()
// Create a new slice with the same length as the source
newSlice := reflect.MakeSlice(targetSlice.Type(), sourceLen, sourceLen)
// Convert each element
for i := 0; i < sourceLen; i++ {
sourceElem := sourceSlice.Index(i)
targetElem := newSlice.Index(i)
// Get the actual value from the source element
var sourceValue interface{}
if sourceElem.CanInterface() {
sourceValue = sourceElem.Interface()
} else {
continue
}
// Handle nil elements
if sourceValue == nil {
// For pointer types, nil is valid
if targetElemType.Kind() == reflect.Ptr {
targetElem.Set(reflect.Zero(targetElemType))
}
continue
}
// If target element type is a pointer to struct, we need to create new instances
if targetElemType.Kind() == reflect.Ptr {
// Create a new instance of the pointed-to type
newElemPtr := reflect.New(targetElemType.Elem())
// Convert the source value to the struct
switch sv := sourceValue.(type) {
case map[string]interface{}:
// Source is a map, use MapToStruct to populate the new instance
if err := MapToStruct(sv, newElemPtr.Interface()); err != nil {
return fmt.Errorf("failed to convert element %d: %w", i, err)
}
default:
// Try direct conversion or setFieldValue
if err := setFieldValue(newElemPtr.Elem(), sourceValue); err != nil {
return fmt.Errorf("failed to convert element %d: %w", i, err)
}
}
targetElem.Set(newElemPtr)
} else if targetElemType.Kind() == reflect.Struct {
// Target element is a struct (not a pointer)
switch sv := sourceValue.(type) {
case map[string]interface{}:
// Use MapToStruct to populate the element
elemPtr := targetElem.Addr()
if elemPtr.CanInterface() {
if err := MapToStruct(sv, elemPtr.Interface()); err != nil {
return fmt.Errorf("failed to convert element %d: %w", i, err)
}
}
default:
// Try direct conversion
if err := setFieldValue(targetElem, sourceValue); err != nil {
return fmt.Errorf("failed to convert element %d: %w", i, err)
}
}
} else {
// For other types, use setFieldValue
if err := setFieldValue(targetElem, sourceValue); err != nil {
return fmt.Errorf("failed to convert element %d: %w", i, err)
}
}
}
// Set the converted slice to the target field
targetSlice.Set(newSlice)
return nil
}
// convertToInt64 attempts to convert various types to int64 // convertToInt64 attempts to convert various types to int64
func convertToInt64(value interface{}) (int64, bool) { func convertToInt64(value interface{}) (int64, bool) {
switch v := value.(type) { switch v := value.(type) {

View File

@@ -698,20 +698,83 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
} }
// Standard processing without nested relations // 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 != "" { if urlID != "" {
logger.Debug("Updating by URL ID: %s", 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 { } else if reqID != nil {
switch id := reqID.(type) { switch id := reqID.(type) {
case string: case string:
logger.Debug("Updating by request ID: %s", id) 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: case []string:
logger.Debug("Updating by multiple IDs: %v", id) if len(id) > 0 {
query = query.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), id) 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 // Standard batch update without nested relations
pkName := reflection.GetPrimaryKeyName(model)
err := h.db.RunInTransaction(ctx, func(tx common.Database) error { err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range updates { for _, item := range updates {
if itemID, ok := item["id"]; ok { 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 { if _, err := txQuery.Exec(ctx); err != nil {
return err return err
} }
@@ -857,13 +951,44 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
} }
// Standard batch update without nested relations // Standard batch update without nested relations
pkName := reflection.GetPrimaryKeyName(model)
list := make([]interface{}, 0) list := make([]interface{}, 0)
err := h.db.RunInTransaction(ctx, func(tx common.Database) error { err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range updates { for _, item := range updates {
if itemMap, ok := item.(map[string]interface{}); ok { if itemMap, ok := item.(map[string]interface{}); ok {
if itemID, ok := itemMap["id"]; 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 { if _, err := txQuery.Exec(ctx); err != nil {
return err 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 // Create temporary nested processor with transaction
txNestedProcessor := common.NewNestedCUDProcessor(tx, h.registry, h) 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) // Extract nested relations if present (but don't process them yet)
var nestedRelations map[string]interface{} var nestedRelations map[string]interface{}
if h.shouldUseNestedProcessor(dataMap, model) { if h.shouldUseNestedProcessor(dataMap, model) {
@@ -1251,15 +1271,30 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
nestedRelations = relations 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 // 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) // Populate model instance from dataMap to preserve custom types (like SqlJSONB)
// Get the type of the model, handling both pointer and non-pointer types // Get the type of the model, handling both pointer and non-pointer types
modelType := reflect.TypeOf(model) modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Ptr { modelType = reflection.GetPointerElement(modelType)
modelType = modelType.Elem()
}
modelInstance := reflect.New(modelType).Interface() modelInstance := reflect.New(modelType).Interface()
if err := reflection.MapToStruct(dataMap, modelInstance); err != nil { if err := reflection.MapToStruct(dataMap, modelInstance); err != nil {
return fmt.Errorf("failed to populate model from data: %w", err) 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 // Fetch the updated record to return the new values
modelValue := reflect.New(reflect.TypeOf(model)).Interface() 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 { if err := selectQuery.ScanModel(ctx); err != nil {
return fmt.Errorf("failed to fetch updated record: %w", err) 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 // First, fetch the record that will be deleted
modelType := reflect.TypeOf(model) modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Ptr { modelType = reflection.GetPointerElement(modelType)
modelType = modelType.Elem()
}
recordToDelete := reflect.New(modelType).Interface() recordToDelete := reflect.New(modelType).Interface()
selectQuery := h.db.NewSelect().Model(recordToDelete).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id) selectQuery := h.db.NewSelect().Model(recordToDelete).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)