fix(delete): run delete hooks and queries in one transaction

- resolvespec/restheadspec: single and batch delete use one transaction
- add sqlmock tests for delete transaction behaviour
- testmodels: serial integer ids; update tests accordingly
- add compose testserver, smoke script, podman-first Makefile targets
This commit is contained in:
2026-09-30 22:33:25 +02:00
parent b2b815552f
commit cd96404cdd
12 changed files with 788 additions and 226 deletions
+168
View File
@@ -0,0 +1,168 @@
package resolvespec
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/database"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
)
type delItem struct {
ID int `json:"id" bun:"id,pk"`
Name string `json:"name" bun:"name"`
}
func newDeleteHarness(t *testing.T) (*Handler, sqlmock.Sqlmock, *sql.DB) {
t.Helper()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
// One connection: any statement that bypasses the transaction while it is
// open cannot get a connection and fails on the request context timeout.
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
return NewHandler(database.NewPgSQLAdapter(db), modelregistry.NewModelRegistry()), mock, db
}
func runDelete(h *Handler, id string, data interface{}) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
w, _ := common.WrapHTTPRequest(rec, httptest.NewRequest(http.MethodPost, "/", nil))
base, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
ctx := WithRequestData(base, "public", "items", "items", &delItem{}, &delItem{})
h.handleDelete(ctx, w, id, data)
return rec
}
// recordDeleteHook registers a BeforeDelete hook that captures hookCtx.Tx.
func recordDeleteHook(h *Handler, hookErr error) *[]common.Database {
var seen []common.Database
h.Hooks().Register(BeforeDelete, func(ctx *HookContext) error {
seen = append(seen, ctx.Tx)
return hookErr
})
return &seen
}
func TestDeleteSingleUsesOneTransaction(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
seen := recordDeleteHook(h, nil)
mock.ExpectBegin()
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(7, "a"))
mock.ExpectExec(`DELETE FROM`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
rec := runDelete(h, "7", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
if len(*seen) != 1 || (*seen)[0] == h.db {
t.Fatalf("BeforeDelete must run once on the transaction, got %v", *seen)
}
}
func TestDeleteSingleNotFoundRollsBack(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
mock.ExpectBegin()
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}))
mock.ExpectRollback()
if rec := runDelete(h, "7", nil); rec.Code != http.StatusNotFound {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestDeleteHookErrorRollsBackWithoutQueries(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
recordDeleteHook(h, errors.New("denied"))
mock.ExpectBegin()
mock.ExpectRollback()
if rec := runDelete(h, "7", nil); rec.Code != http.StatusForbidden {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestDeleteSingleExecErrorRollsBack(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
mock.ExpectBegin()
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(7, "a"))
mock.ExpectExec(`DELETE FROM`).WillReturnError(errors.New("boom"))
mock.ExpectRollback()
if rec := runDelete(h, "7", nil); rec.Code != http.StatusInternalServerError {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestDeleteBatchUsesOneTransaction(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
seen := recordDeleteHook(h, nil)
mock.ExpectBegin()
mock.ExpectExec(`DELETE FROM`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`DELETE FROM`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
rec := runDelete(h, "", []interface{}{"1", "2"})
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
if len(*seen) != 1 || (*seen)[0] == h.db {
t.Fatalf("BeforeDelete must run once on the transaction, got %v", *seen)
}
var resp struct {
Data map[string]float64 `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil || resp.Data["deleted"] != 2 {
t.Fatalf("unexpected body %s (%v)", rec.Body, err)
}
}
func TestDeleteBatchFailureRollsBackAll(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
mock.ExpectBegin()
mock.ExpectExec(`DELETE FROM`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`DELETE FROM`).WillReturnError(errors.New("boom"))
mock.ExpectRollback()
if rec := runDelete(h, "", []string{"1", "2"}); rec.Code != http.StatusInternalServerError {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
+101 -115
View File
@@ -1210,7 +1210,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// Now read the existing record from the database
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := tx.NewSelect().Model(existingRecord).Column(reflection.GetSQLModelColumns(model)...)
selectQuery := h.db.NewSelect().Model(existingRecord).Column(reflection.GetSQLModelColumns(model)...)
// Apply conditions to select, based on the resolved target ID
// (URL ID, request ID, or the "id" field embedded in the data payload).
@@ -1375,7 +1375,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// First, read the existing record
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := tx.NewSelect().Model(existingRecord).Column(reflection.GetSQLModelColumns(model)...).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
selectQuery := h.db.NewSelect().Model(existingRecord).Column(reflection.GetSQLModelColumns(model)...).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
@@ -1524,7 +1524,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// First, read the existing record
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := tx.NewSelect().Model(existingRecord).Column(reflection.GetSQLModelColumns(model)...).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
selectQuery := h.db.NewSelect().Model(existingRecord).Column(reflection.GetSQLModelColumns(model)...).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
@@ -1640,7 +1640,6 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
logger.Info("Deleting records from %s.%s", schema, entity)
// Execute BeforeDelete hooks (covers model-rule checks before any deletion)
hookCtx := &HookContext{
Context: ctx,
Handler: h,
@@ -1653,118 +1652,123 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Writer: w,
Tx: h.db,
}
// Hook, lookup and delete(s) share one transaction so transaction-local
// state set by hooks (e.g. RLS settings) applies to every statement.
var payload interface{}
var failure *deleteFailure
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx.Tx = tx
payload, failure = h.executeDelete(ctx, tx, hookCtx, schema, tableName, model, id, data)
if failure != nil {
return failure
}
return nil
})
if failure != nil {
h.sendError(w, failure.status, failure.code, failure.message, failure.err)
return
}
if txErr != nil {
logger.Error("Error in delete transaction: %v", txErr)
h.sendError(w, http.StatusInternalServerError, "delete_error", "Error deleting records", txErr)
return
}
// Invalidate cache for this table after commit
cacheTags := buildCacheTags(schema, tableName)
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
}
h.sendResponse(w, payload, nil)
}
// deleteFailure describes an error response for a delete; returning it from the
// transaction closure rolls the transaction back.
type deleteFailure struct {
status int
code string
message string
err error
}
func (f *deleteFailure) Error() string { return f.message }
// executeDelete runs the BeforeDelete hook and the delete(s) on tx and returns
// the response payload.
func (h *Handler) executeDelete(ctx context.Context, tx common.Database, hookCtx *HookContext, schema, tableName string, model interface{}, id string, data interface{}) (interface{}, *deleteFailure) {
// Execute BeforeDelete hooks (covers model-rule checks before any deletion)
if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed: %v", err)
h.sendError(w, http.StatusForbidden, "delete_forbidden", "Delete operation not allowed", err)
return
return nil, &deleteFailure{http.StatusForbidden, "delete_forbidden", "Delete operation not allowed", err}
}
pkName := reflection.GetPrimaryKeyName(model)
deleteByID := func(itemID interface{}) (int, error) {
result, err := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID).Exec(ctx)
if err != nil {
return 0, fmt.Errorf("failed to delete record %v: %w", itemID, err)
}
return int(result.RowsAffected()), nil
}
batchFailure := func(err error) *deleteFailure {
logger.Error("Error in batch delete: %v", err)
return &deleteFailure{http.StatusInternalServerError, "delete_error", "Error deleting records", err}
}
// Handle batch delete from request data
if data != nil {
switch v := data.(type) {
case []string:
// Array of IDs as strings
logger.Info("Batch delete with %d IDs ([]string)", len(v))
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, itemID := range v {
query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
if _, err := query.Exec(ctx); err != nil {
return fmt.Errorf("failed to delete record %s: %w", itemID, err)
}
for _, itemID := range v {
if _, err := deleteByID(itemID); err != nil {
return nil, batchFailure(err)
}
return nil
})
if err != nil {
logger.Error("Error in batch delete: %v", err)
h.sendError(w, http.StatusInternalServerError, "delete_error", "Error deleting records", err)
return
}
logger.Info("Successfully deleted %d records", len(v))
// Invalidate cache for this table
cacheTags := buildCacheTags(schema, tableName)
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
}
h.sendResponse(w, map[string]interface{}{"deleted": len(v)}, nil)
return
return map[string]interface{}{"deleted": len(v)}, nil
case []interface{}:
// Array of IDs or objects with ID field
logger.Info("Batch delete with %d items ([]interface{})", len(v))
deletedCount := 0
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range v {
var itemID interface{}
// Check if item is a string ID or object with id field
switch v := item.(type) {
case string:
itemID = v
case map[string]interface{}:
itemID = v["id"]
default:
// Try to use the item directly as ID
itemID = item
}
if itemID == nil {
continue // Skip items without ID
}
query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
result, err := query.Exec(ctx)
if err != nil {
return fmt.Errorf("failed to delete record %v: %w", itemID, err)
}
deletedCount += int(result.RowsAffected())
for _, item := range v {
var itemID interface{}
switch iv := item.(type) {
case string:
itemID = iv
case map[string]interface{}:
itemID = iv["id"]
default:
itemID = item
}
return nil
})
if err != nil {
logger.Error("Error in batch delete: %v", err)
h.sendError(w, http.StatusInternalServerError, "delete_error", "Error deleting records", err)
return
if itemID == nil {
continue // Skip items without ID
}
n, err := deleteByID(itemID)
if err != nil {
return nil, batchFailure(err)
}
deletedCount += n
}
logger.Info("Successfully deleted %d records", deletedCount)
// Invalidate cache for this table
cacheTags := buildCacheTags(schema, tableName)
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
}
h.sendResponse(w, map[string]interface{}{"deleted": deletedCount}, nil)
return
return map[string]interface{}{"deleted": deletedCount}, nil
case []map[string]interface{}:
// Array of objects with id field
logger.Info("Batch delete with %d items ([]map[string]interface{})", len(v))
deletedCount := 0
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range v {
if itemID, ok := item["id"]; ok && itemID != nil {
query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
result, err := query.Exec(ctx)
if err != nil {
return fmt.Errorf("failed to delete record %v: %w", itemID, err)
}
deletedCount += int(result.RowsAffected())
for _, item := range v {
if itemID, ok := item["id"]; ok && itemID != nil {
n, err := deleteByID(itemID)
if err != nil {
return nil, batchFailure(err)
}
deletedCount += n
}
return nil
})
if err != nil {
logger.Error("Error in batch delete: %v", err)
h.sendError(w, http.StatusInternalServerError, "delete_error", "Error deleting records", err)
return
}
logger.Info("Successfully deleted %d records", deletedCount)
// Invalidate cache for this table
cacheTags := buildCacheTags(schema, tableName)
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
}
h.sendResponse(w, map[string]interface{}{"deleted": deletedCount}, nil)
return
return map[string]interface{}{"deleted": deletedCount}, nil
case map[string]interface{}:
// Single object with id field
@@ -1777,13 +1781,9 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
// Single delete with URL ID
if id == "" {
logger.Error("Delete operation requires an ID")
h.sendError(w, http.StatusBadRequest, "missing_id", "Delete operation requires an ID", nil)
return
return nil, &deleteFailure{http.StatusBadRequest, "missing_id", "Delete operation requires an ID", nil}
}
// Get primary key name
pkName := reflection.GetPrimaryKeyName(model)
// First, fetch the record that will be deleted
modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Pointer {
@@ -1791,42 +1791,28 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
}
recordToDelete := reflect.New(modelType).Interface()
selectQuery := h.db.NewSelect().Model(recordToDelete).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
selectQuery := tx.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
return nil, &deleteFailure{http.StatusNotFound, "not_found", "Record not found", err}
}
logger.Error("Error fetching record for delete: %v", err)
h.sendError(w, http.StatusInternalServerError, "fetch_error", "Error fetching record", err)
return
return nil, &deleteFailure{http.StatusInternalServerError, "fetch_error", "Error fetching record", err}
}
query := h.db.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
result, err := query.Exec(ctx)
n, err := deleteByID(id)
if err != nil {
logger.Error("Error deleting record: %v", err)
h.sendError(w, http.StatusInternalServerError, "delete_error", "Error deleting record", err)
return
return nil, &deleteFailure{http.StatusInternalServerError, "delete_error", "Error deleting record", err}
}
// Check if the record was actually deleted
if result.RowsAffected() == 0 {
if n == 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
return nil, &deleteFailure{http.StatusNotFound, "not_found", "Record not found or already deleted", nil}
}
logger.Info("Successfully deleted record with ID: %s", id)
// Return the deleted record data
// Invalidate cache for this table
cacheTags := buildCacheTags(schema, tableName)
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
}
h.sendResponse(w, recordToDelete, nil)
return recordToDelete, nil
}
// applyFilters applies all filters with proper grouping for OR logic
+171
View File
@@ -0,0 +1,171 @@
package restheadspec
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/database"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
)
type delItem struct {
ID int `json:"id" bun:"id,pk"`
Name string `json:"name" bun:"name"`
}
func newDeleteHarness(t *testing.T) (*Handler, sqlmock.Sqlmock, *sql.DB) {
t.Helper()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
// One connection: any statement that bypasses the transaction while it is
// open cannot get a connection and fails on the request context timeout.
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
return NewHandler(database.NewPgSQLAdapter(db), modelregistry.NewModelRegistry()), mock, db
}
func runDelete(h *Handler, id string, data interface{}) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
w, _ := common.WrapHTTPRequest(rec, httptest.NewRequest(http.MethodPost, "/", nil))
base, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
ctx := WithSchema(base, "public")
ctx = WithEntity(ctx, "items")
ctx = WithTableName(ctx, "items")
ctx = WithModel(ctx, &delItem{})
h.handleDelete(ctx, w, id, data)
return rec
}
// recordDeleteHook registers a BeforeDelete hook that captures hookCtx.Tx.
func recordDeleteHook(h *Handler, hookErr error) *[]common.Database {
var seen []common.Database
h.Hooks().Register(BeforeDelete, func(ctx *HookContext) error {
seen = append(seen, ctx.Tx)
return hookErr
})
return &seen
}
func TestDeleteSingleUsesOneTransaction(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
seen := recordDeleteHook(h, nil)
mock.ExpectBegin()
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(7, "a"))
mock.ExpectExec(`DELETE FROM`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
rec := runDelete(h, "7", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
if len(*seen) != 1 || (*seen)[0] == h.db {
t.Fatalf("BeforeDelete must run once on the transaction, got %v", *seen)
}
}
func TestDeleteSingleNotFoundRollsBack(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
mock.ExpectBegin()
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}))
mock.ExpectRollback()
if rec := runDelete(h, "7", nil); rec.Code != http.StatusNotFound {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestDeleteHookErrorRollsBack(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
recordDeleteHook(h, errors.New("denied"))
mock.ExpectBegin()
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(7, "a"))
mock.ExpectRollback()
if rec := runDelete(h, "7", nil); rec.Code != http.StatusBadRequest {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestDeleteSingleExecErrorRollsBack(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
mock.ExpectBegin()
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(7, "a"))
mock.ExpectExec(`DELETE FROM`).WillReturnError(errors.New("boom"))
mock.ExpectRollback()
if rec := runDelete(h, "7", nil); rec.Code != http.StatusInternalServerError {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestDeleteBatchUsesOneTransaction(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
seen := recordDeleteHook(h, nil)
mock.ExpectBegin()
mock.ExpectExec(`DELETE FROM`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`DELETE FROM`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
rec := runDelete(h, "", []interface{}{"1", "2"})
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
// restheadspec fires the hook per item
if len(*seen) != 2 || (*seen)[0] == h.db || (*seen)[1] == h.db {
t.Fatalf("BeforeDelete must run per item on the transaction, got %v", *seen)
}
var resp map[string]float64
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil || resp["deleted"] != 2 {
t.Fatalf("unexpected body %s (%v)", rec.Body, err)
}
}
func TestDeleteBatchFailureRollsBackAll(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
mock.ExpectBegin()
mock.ExpectExec(`DELETE FROM`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`DELETE FROM`).WillReturnError(errors.New("boom"))
mock.ExpectRollback()
if rec := runDelete(h, "", []string{"1", "2"}); rec.Code != http.StatusInternalServerError {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
+54 -28
View File
@@ -1576,7 +1576,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// Now 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)
selectQuery := h.db.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)
@@ -1934,24 +1934,62 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
return
}
// Get primary key name
pkName := reflection.GetPrimaryKeyName(model)
// First, fetch the record that will be deleted
modelType := reflect.TypeOf(model)
modelType = reflection.GetPointerElement(modelType)
recordToDelete := reflect.New(modelType).Interface()
selectQuery := h.db.NewSelect().Model(recordToDelete).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
// Lookup, hooks and delete share one transaction so transaction-local
// state set by hooks (e.g. RLS settings) applies to every statement.
var failure *deleteFailure
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
failure = h.deleteSingleInTx(ctx, tx, w, schema, entity, tableName, model, pkName, id, recordToDelete)
if failure != nil {
return failure
}
return nil
})
if failure != nil {
h.sendError(w, failure.status, failure.code, failure.message, failure.err)
return
}
if txErr != nil {
logger.Error("Error in delete transaction: %v", txErr)
h.sendError(w, http.StatusInternalServerError, "delete_error", "Error deleting record", txErr)
return
}
// Invalidate cache for this table after commit
cacheTags := buildCacheTags(schema, tableName)
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
}
h.sendResponse(w, recordToDelete, nil)
}
// deleteFailure describes an error response for a delete; returning it from the
// transaction closure rolls the transaction back.
type deleteFailure struct {
status int
code string
message string
err error
}
func (f *deleteFailure) Error() string { return f.message }
// deleteSingleInTx fetches the record, runs the delete hooks and deletes it, all on tx.
func (h *Handler) deleteSingleInTx(ctx context.Context, tx common.Database, w common.ResponseWriter, schema, entity, tableName string, model interface{}, pkName, id string, recordToDelete interface{}) *deleteFailure {
// First, fetch the record that will be deleted
selectQuery := tx.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
return &deleteFailure{http.StatusNotFound, "not_found", "Record not found", err}
}
logger.Error("Error fetching record for delete: %v", err)
h.sendError(w, http.StatusInternalServerError, "fetch_error", "Error fetching record", err)
return
return &deleteFailure{http.StatusInternalServerError, "fetch_error", "Error fetching record", err}
}
// Execute BeforeDelete hooks with the record data
@@ -1965,25 +2003,23 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Operation: "delete",
ID: id,
Writer: w,
Tx: h.db,
Tx: tx,
Data: recordToDelete,
}
if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
return &deleteFailure{http.StatusBadRequest, "hook_error", "Hook execution failed", err}
}
query := h.db.NewDelete().Table(tableName)
query := tx.NewDelete().Table(tableName)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
// Execute BeforeScan hooks - pass query chain so hooks can modify it
hookCtx.Query = query
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
return &deleteFailure{http.StatusBadRequest, "hook_error", "Hook execution failed", err}
}
// Use potentially modified query from hook context
@@ -1994,15 +2030,13 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
result, err := query.Exec(ctx)
if err != nil {
logger.Error("Error deleting record: %v", err)
h.sendError(w, http.StatusInternalServerError, "delete_error", "Error deleting record", err)
return
return &deleteFailure{http.StatusInternalServerError, "delete_error", "Error deleting record", err}
}
// 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
return &deleteFailure{http.StatusNotFound, "not_found", "Record not found or already deleted", nil}
}
// Execute AfterDelete hooks with the deleted record data
@@ -2011,17 +2045,9 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
if err := h.hooks.Execute(AfterDelete, hookCtx); err != nil {
logger.Error("AfterDelete hook failed: %v", err)
h.sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
return
return &deleteFailure{http.StatusInternalServerError, "hook_error", "Hook execution failed", err}
}
// Return the deleted record data
// Invalidate cache for this table
cacheTags := buildCacheTags(schema, tableName)
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
}
h.sendResponse(w, recordToDelete, nil)
return nil
}
// mergeRecordWithRequest merges a database record with the original request data
+14 -14
View File
@@ -8,7 +8,7 @@ import (
// Department represents a company department
type Department struct {
ID string `json:"id" gorm:"primaryKey;type:string"`
ID int32 `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name"`
Code string `json:"code" gorm:"uniqueIndex"`
Description string `json:"description"`
@@ -26,13 +26,13 @@ func (Department) TableName() string {
// Employee represents a company employee
type Employee struct {
ID string `json:"id" gorm:"primaryKey;type:string"`
ID int32 `json:"id" gorm:"primaryKey;autoIncrement"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email" gorm:"uniqueIndex"`
Title string `json:"title"`
DepartmentID string `json:"department_id" gorm:"type:string"`
ManagerID *string `json:"manager_id" gorm:"type:string"`
DepartmentID int32 `json:"department_id"`
ManagerID *int32 `json:"manager_id"`
HireDate time.Time `json:"hire_date"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
@@ -52,7 +52,7 @@ func (Employee) TableName() string {
// Project represents a company project
type Project struct {
ID string `json:"id" gorm:"primaryKey;type:string"`
ID int32 `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name"`
Code string `json:"code" gorm:"uniqueIndex"`
Description string `json:"description"`
@@ -76,9 +76,9 @@ func (Project) TableName() string {
// ProjectTask represents a task within a project
type ProjectTask struct {
ID string `json:"id" gorm:"primaryKey;type:string"`
ProjectID string `json:"project_id" gorm:"type:string"`
AssigneeID string `json:"assignee_id" gorm:"type:string"`
ID int32 `json:"id" gorm:"primaryKey;autoIncrement"`
ProjectID int32 `json:"project_id"`
AssigneeID int32 `json:"assignee_id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
@@ -99,14 +99,14 @@ func (ProjectTask) TableName() string {
// Document represents any document in the system
type Document struct {
ID string `json:"id" gorm:"primaryKey;type:string"`
ID int32 `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name"`
Type string `json:"type"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
Path string `json:"path"`
OwnerID string `json:"owner_id" gorm:"type:string"`
ProjectID *string `json:"project_id" gorm:"type:string"`
OwnerID int32 `json:"owner_id"`
ProjectID *int32 `json:"project_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -122,9 +122,9 @@ func (Document) TableName() string {
// Comment represents a comment on a task
type Comment struct {
ID string `json:"id" gorm:"primaryKey;type:string"`
TaskID string `json:"task_id" gorm:"type:string"`
AuthorID string `json:"author_id" gorm:"type:string"`
ID int32 `json:"id" gorm:"primaryKey;autoIncrement"`
TaskID int32 `json:"task_id"`
AuthorID int32 `json:"author_id"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`