mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2025-12-29 07:44:25 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5c0608f63 | ||
|
|
39c3f05d21 | ||
|
|
4ecd1ac17e | ||
|
|
2b1aea0338 | ||
|
|
1e749efeb3 | ||
|
|
09be676096 | ||
|
|
e8350a70be | ||
|
|
5937b9eab5 | ||
|
|
7c861c708e | ||
|
|
77f39af2f9 | ||
|
|
fbc1471581 |
@@ -6,6 +6,7 @@ import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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])
|
||||
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")
|
||||
if valField.IsValid() && valField.CanSet() {
|
||||
// Also set Valid field to true
|
||||
@@ -1095,6 +1144,7 @@ func setFieldValue(field reflect.Value, value interface{}) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// If we can convert the type, do it
|
||||
|
||||
@@ -4,15 +4,15 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/reflection"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
)
|
||||
|
||||
func TestMapToStruct_SqlJSONB_PreservesDriverValuer(t *testing.T) {
|
||||
// Test that SqlJSONB type preserves driver.Valuer interface
|
||||
type TestModel struct {
|
||||
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{}{
|
||||
@@ -65,7 +65,7 @@ func TestMapToStruct_SqlJSONB_FromBytes(t *testing.T) {
|
||||
// Test that SqlJSONB can be set from []byte directly
|
||||
type TestModel struct {
|
||||
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"}`)
|
||||
@@ -103,11 +103,11 @@ func TestMapToStruct_AllSqlTypes(t *testing.T) {
|
||||
type TestModel struct {
|
||||
ID int64 `bun:"id,pk" json:"id"`
|
||||
Name string `bun:"name" json:"name"`
|
||||
CreatedAt common.SqlTimeStamp `bun:"created_at" json:"created_at"`
|
||||
BirthDate common.SqlDate `bun:"birth_date" json:"birth_date"`
|
||||
LoginTime common.SqlTime `bun:"login_time" json:"login_time"`
|
||||
Meta common.SqlJSONB `bun:"meta" json:"meta"`
|
||||
Tags common.SqlJSONB `bun:"tags" json:"tags"`
|
||||
CreatedAt spectypes.SqlTimeStamp `bun:"created_at" json:"created_at"`
|
||||
BirthDate spectypes.SqlDate `bun:"birth_date" json:"birth_date"`
|
||||
LoginTime spectypes.SqlTime `bun:"login_time" json:"login_time"`
|
||||
Meta spectypes.SqlJSONB `bun:"meta" json:"meta"`
|
||||
Tags spectypes.SqlJSONB `bun:"tags" json:"tags"`
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
@@ -225,8 +225,8 @@ func TestMapToStruct_SqlNull_NilValues(t *testing.T) {
|
||||
// Test that SqlNull types handle nil values correctly
|
||||
type TestModel struct {
|
||||
ID int64 `bun:"id,pk" json:"id"`
|
||||
UpdatedAt common.SqlTimeStamp `bun:"updated_at" json:"updated_at"`
|
||||
DeletedAt common.SqlTimeStamp `bun:"deleted_at" json:"deleted_at"`
|
||||
UpdatedAt spectypes.SqlTimeStamp `bun:"updated_at" json:"updated_at"`
|
||||
DeletedAt spectypes.SqlTimeStamp `bun:"deleted_at" json:"deleted_at"`
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
@@ -2,6 +2,7 @@ package resolvespec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -957,7 +958,29 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -966,14 +989,16 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the record was actually deleted
|
||||
if result.RowsAffected() == 0 {
|
||||
logger.Warn("No record found to delete with ID: %s", id)
|
||||
h.sendError(w, http.StatusNotFound, "not_found", "Record not found", nil)
|
||||
logger.Warn("No rows deleted for ID: %s", id)
|
||||
h.sendError(w, http.StatusNotFound, "not_found", "Record not found or already deleted", nil)
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package restheadspec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -661,6 +662,14 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
return
|
||||
}
|
||||
|
||||
// Check if a specific ID was requested but no record was found
|
||||
resultCount := reflection.Len(modelPtr)
|
||||
if id != "" && resultCount == 0 {
|
||||
logger.Warn("Record not found for ID: %s", id)
|
||||
h.sendError(w, http.StatusNotFound, "not_found", "Record not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
limit := 0
|
||||
if options.Limit != nil {
|
||||
limit = *options.Limit
|
||||
@@ -675,7 +684,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
|
||||
metadata := &common.Metadata{
|
||||
Total: int64(total),
|
||||
Count: int64(reflection.Len(modelPtr)),
|
||||
Count: int64(resultCount),
|
||||
Filtered: int64(total),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
@@ -1236,13 +1245,18 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
|
||||
dataMap[pkName] = targetID
|
||||
|
||||
// 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 {
|
||||
return fmt.Errorf("failed to populate model from data: %w", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Execute BeforeScan hooks - pass query chain so hooks can modify it
|
||||
@@ -1509,7 +1523,34 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, 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{
|
||||
Context: ctx,
|
||||
Handler: h,
|
||||
@@ -1520,6 +1561,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
||||
ID: id,
|
||||
Writer: w,
|
||||
Tx: h.db,
|
||||
Data: recordToDelete,
|
||||
}
|
||||
|
||||
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
|
||||
@@ -1529,13 +1571,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
||||
}
|
||||
|
||||
query := h.db.NewDelete().Table(tableName)
|
||||
|
||||
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)
|
||||
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
|
||||
|
||||
// Execute BeforeScan hooks - pass query chain so hooks can modify it
|
||||
hookCtx.Query = query
|
||||
@@ -1557,11 +1593,15 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
||||
return
|
||||
}
|
||||
|
||||
// Execute AfterDelete hooks
|
||||
responseData := map[string]interface{}{
|
||||
"deleted": result.RowsAffected(),
|
||||
// Check if the record was actually deleted
|
||||
if result.RowsAffected() == 0 {
|
||||
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
|
||||
|
||||
if err := h.hooks.Execute(AfterDelete, hookCtx); err != nil {
|
||||
@@ -1570,7 +1610,8 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
||||
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
|
||||
@@ -2066,14 +2107,20 @@ func (h *Handler) sendResponse(w common.ResponseWriter, data interface{}, metada
|
||||
|
||||
// sendResponseWithOptions sends a response with optional formatting
|
||||
func (h *Handler) sendResponseWithOptions(w common.ResponseWriter, data interface{}, metadata *common.Metadata, options *ExtendedRequestOptions) {
|
||||
w.SetHeader("Content-Type", "application/json")
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
// Normalize single-record arrays to objects if requested
|
||||
if options != nil && options.SingleRecordAsObject {
|
||||
data = h.normalizeResultArray(data)
|
||||
}
|
||||
|
||||
// Return data as-is without wrapping in common.Response
|
||||
w.SetHeader("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
if err := w.WriteJSON(data); err != nil {
|
||||
logger.Error("Failed to write JSON response: %v", err)
|
||||
}
|
||||
@@ -2083,7 +2130,7 @@ func (h *Handler) sendResponseWithOptions(w common.ResponseWriter, data interfac
|
||||
// Returns the single element if data is a slice/array with exactly one element, otherwise returns data unchanged
|
||||
func (h *Handler) normalizeResultArray(data interface{}) interface{} {
|
||||
if data == nil {
|
||||
return nil
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
// Use reflection to check if data is a slice or array
|
||||
@@ -2092,18 +2139,41 @@ func (h *Handler) normalizeResultArray(data interface{}) interface{} {
|
||||
dataValue = dataValue.Elem()
|
||||
}
|
||||
|
||||
// Check if it's a slice or array with exactly one element
|
||||
if (dataValue.Kind() == reflect.Slice || dataValue.Kind() == reflect.Array) && dataValue.Len() == 1 {
|
||||
// Return the single element
|
||||
return dataValue.Index(0).Interface()
|
||||
// Check if it's a slice or array
|
||||
if dataValue.Kind() == reflect.Slice || dataValue.Kind() == reflect.Array {
|
||||
if dataValue.Len() == 1 {
|
||||
// Return the single element
|
||||
return dataValue.Index(0).Interface()
|
||||
} else if dataValue.Len() == 0 {
|
||||
// Return empty object instead of empty array
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if dataValue.Kind() == reflect.String {
|
||||
str := dataValue.String()
|
||||
if str == "" || str == "null" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// sendFormattedResponse sends response with formatting options
|
||||
func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata, options ExtendedRequestOptions) {
|
||||
// Normalize single-record arrays to objects if requested
|
||||
httpStatus := http.StatusOK
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
httpStatus = http.StatusPartialContent
|
||||
} else {
|
||||
dataLen := reflection.Len(data)
|
||||
if dataLen == 0 {
|
||||
httpStatus = http.StatusPartialContent
|
||||
}
|
||||
}
|
||||
|
||||
if options.SingleRecordAsObject {
|
||||
data = h.normalizeResultArray(data)
|
||||
}
|
||||
@@ -2122,7 +2192,7 @@ func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{
|
||||
switch options.ResponseFormat {
|
||||
case "simple":
|
||||
// Simple format: just return the data array
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.WriteHeader(httpStatus)
|
||||
if err := w.WriteJSON(data); err != nil {
|
||||
logger.Error("Failed to write JSON response: %v", err)
|
||||
}
|
||||
@@ -2134,7 +2204,7 @@ func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{
|
||||
if metadata != nil {
|
||||
response["count"] = metadata.Total
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.WriteHeader(httpStatus)
|
||||
if err := w.WriteJSON(response); err != nil {
|
||||
logger.Error("Failed to write JSON response: %v", err)
|
||||
}
|
||||
@@ -2145,7 +2215,7 @@ func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{
|
||||
Data: data,
|
||||
Metadata: metadata,
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.WriteHeader(httpStatus)
|
||||
if err := w.WriteJSON(response); err != nil {
|
||||
logger.Error("Failed to write JSON response: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package common provides nullable SQL types with automatic casting and conversion methods.
|
||||
package common
|
||||
// Package spectypes provides nullable SQL types with automatic casting and conversion methods.
|
||||
package spectypes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package common
|
||||
package spectypes
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
Reference in New Issue
Block a user