Compare commits

...

6 Commits

Author SHA1 Message Date
Hein
09be676096 Resolvespec delete returns deleted record 2025-12-18 12:52:47 +02:00
Hein
e8350a70be Fixed delete record to return the record 2025-12-18 12:49:37 +02:00
Hein
5937b9eab5 Fixed the double table on update
Some checks are pending
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Waiting to run
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Waiting to run
Build , Vet Test, and Lint / Lint Code (push) Waiting to run
Build , Vet Test, and Lint / Build (push) Waiting to run
Tests / Unit Tests (push) Waiting to run
Tests / Integration Tests (push) Waiting to run
2025-12-18 12:14:39 +02:00
Hein
7c861c708e [breaking] Another breaking change datatypes -> spectypes 2025-12-18 11:49:35 +02:00
Hein
77f39af2f9 [breaking] Moved sql types to datatypes 2025-12-18 11:43:19 +02:00
Hein
fbc1471581 Fixed panic caused by model type not being pointer in rest header spec. 2025-12-18 11:21:59 +02:00
6 changed files with 141 additions and 33 deletions

View File

@@ -6,6 +6,7 @@ import (
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry" "github.com/bitechdev/ResolveSpec/pkg/modelregistry"
) )
@@ -1080,7 +1081,55 @@ func setFieldValue(field reflect.Value, value interface{}) error {
// Handle struct types (like SqlTimeStamp, SqlDate, SqlTime which wrap SqlNull[time.Time]) // Handle struct types (like SqlTimeStamp, SqlDate, SqlTime which wrap SqlNull[time.Time])
if field.Kind() == reflect.Struct { if field.Kind() == reflect.Struct {
// Try to find a "Val" field (for SqlNull types) and set it
// Handle datatypes.SqlNull[T] and wrapped types (SqlTimeStamp, SqlDate, SqlTime)
// Check if the type has a Scan method (sql.Scanner interface)
if field.CanAddr() {
scanMethod := field.Addr().MethodByName("Scan")
if scanMethod.IsValid() {
// Call the Scan method with the value
results := scanMethod.Call([]reflect.Value{reflect.ValueOf(value)})
if len(results) > 0 {
// Check if there was an error
if err, ok := results[0].Interface().(error); ok && err != nil {
return err
}
return nil
}
}
}
// Handle time.Time with ISO string fallback
if field.Type() == reflect.TypeOf(time.Time{}) {
switch v := value.(type) {
case time.Time:
field.Set(reflect.ValueOf(v))
return nil
case string:
// Try parsing as ISO 8601 / RFC3339
if t, err := time.Parse(time.RFC3339, v); err == nil {
field.Set(reflect.ValueOf(t))
return nil
}
// Try other common formats
formats := []string{
"2006-01-02T15:04:05.000-0700",
"2006-01-02T15:04:05.000",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
"2006-01-02",
}
for _, format := range formats {
if t, err := time.Parse(format, v); err == nil {
field.Set(reflect.ValueOf(t))
return nil
}
}
return fmt.Errorf("cannot parse time string: %s", v)
}
}
// Fallback: Try to find a "Val" field (for SqlNull types) and set it directly
valField := field.FieldByName("Val") valField := field.FieldByName("Val")
if valField.IsValid() && valField.CanSet() { if valField.IsValid() && valField.CanSet() {
// Also set Valid field to true // Also set Valid field to true
@@ -1095,6 +1144,7 @@ func setFieldValue(field reflect.Value, value interface{}) error {
return nil return nil
} }
} }
} }
// If we can convert the type, do it // If we can convert the type, do it

View File

@@ -4,15 +4,15 @@ import (
"testing" "testing"
"time" "time"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/reflection" "github.com/bitechdev/ResolveSpec/pkg/reflection"
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
) )
func TestMapToStruct_SqlJSONB_PreservesDriverValuer(t *testing.T) { func TestMapToStruct_SqlJSONB_PreservesDriverValuer(t *testing.T) {
// Test that SqlJSONB type preserves driver.Valuer interface // Test that SqlJSONB type preserves driver.Valuer interface
type TestModel struct { type TestModel struct {
ID int64 `bun:"id,pk" json:"id"` ID int64 `bun:"id,pk" json:"id"`
Meta common.SqlJSONB `bun:"meta" json:"meta"` Meta spectypes.SqlJSONB `bun:"meta" json:"meta"`
} }
dataMap := map[string]interface{}{ dataMap := map[string]interface{}{
@@ -65,7 +65,7 @@ func TestMapToStruct_SqlJSONB_FromBytes(t *testing.T) {
// Test that SqlJSONB can be set from []byte directly // Test that SqlJSONB can be set from []byte directly
type TestModel struct { type TestModel struct {
ID int64 `bun:"id,pk" json:"id"` ID int64 `bun:"id,pk" json:"id"`
Meta common.SqlJSONB `bun:"meta" json:"meta"` Meta spectypes.SqlJSONB `bun:"meta" json:"meta"`
} }
jsonBytes := []byte(`{"direct":"bytes"}`) jsonBytes := []byte(`{"direct":"bytes"}`)
@@ -103,11 +103,11 @@ func TestMapToStruct_AllSqlTypes(t *testing.T) {
type TestModel struct { type TestModel struct {
ID int64 `bun:"id,pk" json:"id"` ID int64 `bun:"id,pk" json:"id"`
Name string `bun:"name" json:"name"` Name string `bun:"name" json:"name"`
CreatedAt common.SqlTimeStamp `bun:"created_at" json:"created_at"` CreatedAt spectypes.SqlTimeStamp `bun:"created_at" json:"created_at"`
BirthDate common.SqlDate `bun:"birth_date" json:"birth_date"` BirthDate spectypes.SqlDate `bun:"birth_date" json:"birth_date"`
LoginTime common.SqlTime `bun:"login_time" json:"login_time"` LoginTime spectypes.SqlTime `bun:"login_time" json:"login_time"`
Meta common.SqlJSONB `bun:"meta" json:"meta"` Meta spectypes.SqlJSONB `bun:"meta" json:"meta"`
Tags common.SqlJSONB `bun:"tags" json:"tags"` Tags spectypes.SqlJSONB `bun:"tags" json:"tags"`
} }
now := time.Now() now := time.Now()
@@ -225,8 +225,8 @@ func TestMapToStruct_SqlNull_NilValues(t *testing.T) {
// Test that SqlNull types handle nil values correctly // Test that SqlNull types handle nil values correctly
type TestModel struct { type TestModel struct {
ID int64 `bun:"id,pk" json:"id"` ID int64 `bun:"id,pk" json:"id"`
UpdatedAt common.SqlTimeStamp `bun:"updated_at" json:"updated_at"` UpdatedAt spectypes.SqlTimeStamp `bun:"updated_at" json:"updated_at"`
DeletedAt common.SqlTimeStamp `bun:"deleted_at" json:"deleted_at"` DeletedAt spectypes.SqlTimeStamp `bun:"deleted_at" json:"deleted_at"`
} }
now := time.Now() now := time.Now()

View File

@@ -2,6 +2,7 @@ package resolvespec
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -957,7 +958,29 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
return return
} }
query := h.db.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), id) // Get primary key name
pkName := reflection.GetPrimaryKeyName(model)
// First, fetch the record that will be deleted
modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Ptr {
modelType = modelType.Elem()
}
recordToDelete := reflect.New(modelType).Interface()
selectQuery := h.db.NewSelect().Model(recordToDelete).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
logger.Warn("Record not found for delete: %s = %s", pkName, id)
h.sendError(w, http.StatusNotFound, "not_found", "Record not found", err)
return
}
logger.Error("Error fetching record for delete: %v", err)
h.sendError(w, http.StatusInternalServerError, "fetch_error", "Error fetching record", err)
return
}
query := h.db.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
result, err := query.Exec(ctx) result, err := query.Exec(ctx)
if err != nil { if err != nil {
@@ -966,14 +989,16 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
return return
} }
// Check if the record was actually deleted
if result.RowsAffected() == 0 { if result.RowsAffected() == 0 {
logger.Warn("No record found to delete with ID: %s", id) logger.Warn("No rows deleted for ID: %s", id)
h.sendError(w, http.StatusNotFound, "not_found", "Record not found", nil) h.sendError(w, http.StatusNotFound, "not_found", "Record not found or already deleted", nil)
return return
} }
logger.Info("Successfully deleted record with ID: %s", id) logger.Info("Successfully deleted record with ID: %s", id)
h.sendResponse(w, nil, nil) // Return the deleted record data
h.sendResponse(w, recordToDelete, nil)
} }
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption) common.SelectQuery { func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption) common.SelectQuery {

View File

@@ -2,6 +2,7 @@ package restheadspec
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -1236,13 +1237,18 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
dataMap[pkName] = targetID dataMap[pkName] = targetID
// Populate model instance from dataMap to preserve custom types (like SqlJSONB) // Populate model instance from dataMap to preserve custom types (like SqlJSONB)
modelInstance := reflect.New(reflect.TypeOf(model).Elem()).Interface() // 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()
}
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)
} }
// Create update query using Model() to preserve custom types and driver.Valuer interfaces // Create update query using Model() to preserve custom types and driver.Valuer interfaces
query := tx.NewUpdate().Model(modelInstance).Table(tableName) query := tx.NewUpdate().Model(modelInstance)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID) query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
// Execute BeforeScan hooks - pass query chain so hooks can modify it // Execute BeforeScan hooks - pass query chain so hooks can modify it
@@ -1509,7 +1515,34 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
} }
// Single delete with URL ID // Single delete with URL ID
// Execute BeforeDelete hooks if id == "" {
h.sendError(w, http.StatusBadRequest, "missing_id", "ID is required for delete", nil)
return
}
// Get primary key name
pkName := reflection.GetPrimaryKeyName(model)
// First, fetch the record that will be deleted
modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Ptr {
modelType = modelType.Elem()
}
recordToDelete := reflect.New(modelType).Interface()
selectQuery := h.db.NewSelect().Model(recordToDelete).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
logger.Warn("Record not found for delete: %s = %s", pkName, id)
h.sendError(w, http.StatusNotFound, "not_found", "Record not found", err)
return
}
logger.Error("Error fetching record for delete: %v", err)
h.sendError(w, http.StatusInternalServerError, "fetch_error", "Error fetching record", err)
return
}
// Execute BeforeDelete hooks with the record data
hookCtx := &HookContext{ hookCtx := &HookContext{
Context: ctx, Context: ctx,
Handler: h, Handler: h,
@@ -1520,6 +1553,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
ID: id, ID: id,
Writer: w, Writer: w,
Tx: h.db, Tx: h.db,
Data: recordToDelete,
} }
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
@@ -1529,13 +1563,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
} }
query := h.db.NewDelete().Table(tableName) query := h.db.NewDelete().Table(tableName)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
if id == "" {
h.sendError(w, http.StatusBadRequest, "missing_id", "ID is required for delete", nil)
return
}
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), id)
// Execute BeforeScan hooks - pass query chain so hooks can modify it // Execute BeforeScan hooks - pass query chain so hooks can modify it
hookCtx.Query = query hookCtx.Query = query
@@ -1557,11 +1585,15 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
return return
} }
// Execute AfterDelete hooks // Check if the record was actually deleted
responseData := map[string]interface{}{ if result.RowsAffected() == 0 {
"deleted": result.RowsAffected(), logger.Warn("No rows deleted for ID: %s", id)
h.sendError(w, http.StatusNotFound, "not_found", "Record not found or already deleted", nil)
return
} }
hookCtx.Result = responseData
// Execute AfterDelete hooks with the deleted record data
hookCtx.Result = recordToDelete
hookCtx.Error = nil hookCtx.Error = nil
if err := h.hooks.Execute(AfterDelete, hookCtx); err != nil { if err := h.hooks.Execute(AfterDelete, hookCtx); err != nil {
@@ -1570,7 +1602,8 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
return return
} }
h.sendResponse(w, responseData, nil) // Return the deleted record data
h.sendResponse(w, recordToDelete, nil)
} }
// mergeRecordWithRequest merges a database record with the original request data // mergeRecordWithRequest merges a database record with the original request data

View File

@@ -1,5 +1,5 @@
// Package common provides nullable SQL types with automatic casting and conversion methods. // Package spectypes provides nullable SQL types with automatic casting and conversion methods.
package common package spectypes
import ( import (
"database/sql" "database/sql"

View File

@@ -1,4 +1,4 @@
package common package spectypes
import ( import (
"database/sql/driver" "database/sql/driver"