mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 19:20:31 +00:00
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:
+101
-115
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user