Compare commits

...

5 Commits

Author SHA1 Message Date
Hein
1e749efeb3 Fixes for not found records 2025-12-18 13:08:26 +02:00
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
5 changed files with 103 additions and 37 deletions

View File

@@ -4,15 +4,15 @@ import (
"testing" "testing"
"time" "time"
"github.com/bitechdev/ResolveSpec/pkg/datatypes"
"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 datatypes.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 datatypes.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 datatypes.SqlTimeStamp `bun:"created_at" json:"created_at"` CreatedAt spectypes.SqlTimeStamp `bun:"created_at" json:"created_at"`
BirthDate datatypes.SqlDate `bun:"birth_date" json:"birth_date"` BirthDate spectypes.SqlDate `bun:"birth_date" json:"birth_date"`
LoginTime datatypes.SqlTime `bun:"login_time" json:"login_time"` LoginTime spectypes.SqlTime `bun:"login_time" json:"login_time"`
Meta datatypes.SqlJSONB `bun:"meta" json:"meta"` Meta spectypes.SqlJSONB `bun:"meta" json:"meta"`
Tags datatypes.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 datatypes.SqlTimeStamp `bun:"updated_at" json:"updated_at"` UpdatedAt spectypes.SqlTimeStamp `bun:"updated_at" json:"updated_at"`
DeletedAt datatypes.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"
@@ -661,6 +662,14 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
return 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 limit := 0
if options.Limit != nil { if options.Limit != nil {
limit = *options.Limit limit = *options.Limit
@@ -675,7 +684,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
metadata := &common.Metadata{ metadata := &common.Metadata{
Total: int64(total), Total: int64(total),
Count: int64(reflection.Len(modelPtr)), Count: int64(resultCount),
Filtered: int64(total), Filtered: int64(total),
Limit: limit, Limit: limit,
Offset: offset, Offset: offset,
@@ -1247,7 +1256,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
} }
// 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
@@ -1514,7 +1523,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,
@@ -1525,6 +1561,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 {
@@ -1534,13 +1571,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
@@ -1562,11 +1593,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 {
@@ -1575,7 +1610,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
@@ -2088,7 +2124,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 // 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{} { func (h *Handler) normalizeResultArray(data interface{}) interface{} {
if data == nil { if data == nil {
return nil return map[string]interface{}{}
} }
// Use reflection to check if data is a slice or array // Use reflection to check if data is a slice or array
@@ -2097,10 +2133,15 @@ func (h *Handler) normalizeResultArray(data interface{}) interface{} {
dataValue = dataValue.Elem() dataValue = dataValue.Elem()
} }
// Check if it's a slice or array with exactly one element // Check if it's a slice or array
if (dataValue.Kind() == reflect.Slice || dataValue.Kind() == reflect.Array) && dataValue.Len() == 1 { if dataValue.Kind() == reflect.Slice || dataValue.Kind() == reflect.Array {
// Return the single element if dataValue.Len() == 1 {
return dataValue.Index(0).Interface() // 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{}{}
}
} }
return data return data

View File

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

View File

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