Compare commits

..

3 Commits

Author SHA1 Message Date
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
5 changed files with 287 additions and 87 deletions

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)