Compare commits

...

4 Commits

Author SHA1 Message Date
Hein 873e8925d4 fix(handler): ensure target ID is provided for updates
Tests / Unit Tests (push) Failing after 9s
Tests / Integration Tests (push) Failing after 12s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 48s
Build , Vet Test, and Lint / Build (push) Successful in 1m18s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 1m27s
Build , Vet Test, and Lint / Lint Code (push) Successful in 1m28s
2026-07-24 16:35:22 +02:00
Hein b23916048a fix: ordering in before hook 2026-07-24 15:56:23 +02:00
Hein 47708fc87a fix(security): run RLS-scoping hooks in the same transaction as their queries
Tests / Unit Tests (push) Failing after 12s
Tests / Integration Tests (push) Failing after 16s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 51s
Build , Vet Test, and Lint / Build (push) Successful in 1m24s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 1m31s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m4s
BeforeRead/BeforeCreate hooks (used to set session-scoped RLS GUCs) were
firing against the pooled db handle while the actual queries ran as
separate calls to the same pool. Under connection pooling these could
land on different physical connections, silently bypassing row-level
security on creates and reads. handleUpdate already did this correctly;
handleRead/handleCreate in both resolvespec and restheadspec now wrap
hook execution and queries in a single RunInTransaction call.
2026-07-24 12:40:27 +02:00
Hein a85e572732 fix(hooks): reset Tx to pooled connection for post-commit hook calls
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m9s
Tests / Unit Tests (push) Failing after 2m40s
Tests / Integration Tests (push) Failing after 2m47s
Build , Vet Test, and Lint / Build (push) Successful in 5m56s
Build , Vet Test, and Lint / Lint Code (push) Failing after 6m37s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 6m48s
BeforeScan (restheadspec handleUpdate) and BeforeResponse (funcspec
list/single query handlers) fire after RunInTransaction commits, but
hookCtx.Tx still pointed at the now-dead transaction. Any hook that
executed a query against Tx (e.g. setUserViaContext) failed with
"sql: transaction has already been committed or rolled back".
2026-07-20 13:45:13 +02:00
3 changed files with 1004 additions and 806 deletions
+8 -2
View File
@@ -331,7 +331,10 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
w.Header().Set("Content-Range", fmt.Sprintf("items %d-%d/%d", respOffset, respOffset+len(dbobjlist), total)) w.Header().Set("Content-Range", fmt.Sprintf("items %d-%d/%d", respOffset, respOffset+len(dbobjlist), total))
logger.Info("Serving: Records %d of %d", len(dbobjlist), total) logger.Info("Serving: Records %d of %d", len(dbobjlist), total)
// Execute BeforeResponse hook // Execute BeforeResponse hook. The transaction has already committed by
// this point, so hooks must use the pooled connection rather than the
// now-dead tx.
hookCtx.Tx = h.db
hookCtx.Result = dbobjlist hookCtx.Result = dbobjlist
hookCtx.Total = total hookCtx.Total = total
if err := h.hooks.Execute(BeforeResponse, hookCtx); err != nil { if err := h.hooks.Execute(BeforeResponse, hookCtx); err != nil {
@@ -631,7 +634,10 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
return return
} }
// Execute BeforeResponse hook // Execute BeforeResponse hook. The transaction has already committed by
// this point, so hooks must use the pooled connection rather than the
// now-dead tx.
hookCtx.Tx = h.db
hookCtx.Result = dbobj hookCtx.Result = dbobj
if err := h.hooks.Execute(BeforeResponse, hookCtx); err != nil { if err := h.hooks.Execute(BeforeResponse, hookCtx); err != nil {
logger.Error("BeforeResponse hook failed: %v", err) logger.Error("BeforeResponse hook failed: %v", err)
+274 -127
View File
@@ -259,9 +259,44 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
sliceType := reflect.SliceOf(reflect.PointerTo(modelType)) sliceType := reflect.SliceOf(reflect.PointerTo(modelType))
modelPtr := reflect.New(sliceType).Interface() modelPtr := reflect.New(sliceType).Interface()
// Everything below runs inside a single transaction so that the BeforeRead
// hook (which sets session-scoped RLS GUCs via SET LOCAL) executes on the
// same physical connection as the queries it is meant to protect. Under
// connection pooling, firing the hook against h.db and then querying
// against h.db again may hand out two different connections, silently
// bypassing RLS.
var (
result interface{}
total int
rowNumber *int64
limit int
offset int
statusCode int
errCode string
errMsg string
)
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: id,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil {
statusCode, errCode, errMsg = http.StatusInternalServerError, "hook_error", "BeforeRead hook failed"
return err
}
options = hookCtx.Options
// Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count() // Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count()
// Bun's Model() accepts both single pointers and slice pointers // Bun's Model() accepts both single pointers and slice pointers
query := h.db.NewSelect().Model(modelPtr) query := tx.NewSelect().Model(modelPtr)
// Only set Table() if the model doesn't provide a table name via the underlying type // Only set Table() if the model doesn't provide a table name via the underlying type
// Create a temporary instance to check for TableNameProvider // Create a temporary instance to check for TableNameProvider
@@ -296,8 +331,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
query, err = h.applyPreloads(model, query, options.Preload) query, err = h.applyPreloads(model, query, options.Preload)
if err != nil { if err != nil {
logger.Error("Failed to apply preloads: %v", err) logger.Error("Failed to apply preloads: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_preload", "Failed to apply preloads", err) statusCode, errCode, errMsg = http.StatusBadRequest, "invalid_preload", "Failed to apply preloads"
return return err
} }
} }
@@ -339,8 +374,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
cursorFilter, err := GetCursorFilter(tableName, pkName, modelColumns, options, nil) cursorFilter, err := GetCursorFilter(tableName, pkName, modelColumns, options, nil)
if err != nil { if err != nil {
logger.Error("Error building cursor filter: %v", err) logger.Error("Error building cursor filter: %v", err)
h.sendError(w, http.StatusBadRequest, "cursor_error", "Invalid cursor pagination", err) statusCode, errCode, errMsg = http.StatusBadRequest, "cursor_error", "Invalid cursor pagination"
return return err
} }
// Apply cursor filter to query // Apply cursor filter to query
@@ -355,9 +390,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
} }
} }
// Get total count before pagination
var total int
// Try to get from cache first // Try to get from cache first
// Use extended cache key if cursors are present // Use extended cache key if cursors are present
var cacheKeyHash string var cacheKeyHash string
@@ -384,8 +416,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// Try to retrieve from cache // Try to retrieve from cache
var cachedTotal cachedTotal var cachedTotal cachedTotal
err := cache.GetDefaultCache().Get(ctx, cacheKey, &cachedTotal) if err := cache.GetDefaultCache().Get(ctx, cacheKey, &cachedTotal); err == nil {
if err == nil {
total = cachedTotal.Total total = cachedTotal.Total
logger.Debug("Total records (from cache): %d", total) logger.Debug("Total records (from cache): %d", total)
} else { } else {
@@ -394,8 +425,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
count, err := query.Count(ctx) count, err := query.Count(ctx)
if err != nil { if err != nil {
logger.Error("Error counting records: %v", err) logger.Error("Error counting records: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error counting records", err) statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error counting records"
return return err
} }
total = count total = count
logger.Debug("Total records (from query): %d", total) logger.Debug("Total records (from query): %d", total)
@@ -411,7 +442,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
} }
// Handle FetchRowNumber if requested // Handle FetchRowNumber if requested
var rowNumber *int64
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" { if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
logger.Debug("Fetching row number for ID: %s", *options.FetchRowNumber) logger.Debug("Fetching row number for ID: %s", *options.FetchRowNumber)
pkName := reflection.GetPrimaryKeyName(model) pkName := reflection.GetPrimaryKeyName(model)
@@ -439,7 +469,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
RowNum int64 `bun:"row_num"` RowNum int64 `bun:"row_num"`
} }
rowNumQuery := h.db.NewSelect().Table(tableName). rowNumQuery := tx.NewSelect().Table(tableName).
ColumnExpr(fmt.Sprintf("%s AS row_num", rowNumberSQL)). ColumnExpr(fmt.Sprintf("%s AS row_num", rowNumberSQL)).
Column(pkName) Column(pkName)
@@ -457,8 +487,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
rowNumQuery = rowNumQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), *options.FetchRowNumber) rowNumQuery = rowNumQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), *options.FetchRowNumber)
// Execute query to get row number // Execute query to get row number
var result RowNumResult var rnResult RowNumResult
if err := rowNumQuery.Scan(ctx, &result); err != nil { if err := rowNumQuery.Scan(ctx, &rnResult); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
// Build filter description for error message // Build filter description for error message
filterInfo := fmt.Sprintf("filters: %d", len(options.Filters)) filterInfo := fmt.Sprintf("filters: %d", len(options.Filters))
@@ -474,7 +504,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Warn("Error fetching row number: %v", err) logger.Warn("Error fetching row number: %v", err)
} }
} else { } else {
rowNumber = &result.RowNum rowNumber = &rnResult.RowNum
logger.Debug("Found row number: %d", *rowNumber) logger.Debug("Found row number: %d", *rowNumber)
} }
} }
@@ -492,7 +522,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
} }
// Execute query // Execute query
var result interface{}
if id != "" || (options.FetchRowNumber != nil && *options.FetchRowNumber != "") { if id != "" || (options.FetchRowNumber != nil && *options.FetchRowNumber != "") {
// Single record query - either by URL ID or FetchRowNumber // Single record query - either by URL ID or FetchRowNumber
var targetID string var targetID string
@@ -511,8 +540,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID) query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
if err := query.Scan(ctx, singleResult); err != nil { if err := query.Scan(ctx, singleResult); err != nil {
logger.Error("Error querying record: %v", err) logger.Error("Error querying record: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err) statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
return return err
} }
result = singleResult result = singleResult
} else { } else {
@@ -520,17 +549,25 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// Use the modelPtr already created and set on the query // Use the modelPtr already created and set on the query
if err := query.Scan(ctx, modelPtr); err != nil { if err := query.Scan(ctx, modelPtr); err != nil {
logger.Error("Error querying records: %v", err) logger.Error("Error querying records: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err) statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
return return err
} }
result = reflect.ValueOf(modelPtr).Elem().Interface() result = reflect.ValueOf(modelPtr).Elem().Interface()
} }
logger.Info("Successfully retrieved records") logger.Info("Successfully retrieved records")
return nil
})
if txErr != nil {
if statusCode == 0 {
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
}
h.sendError(w, statusCode, errCode, errMsg, txErr)
return
}
// Build metadata // Build metadata
limit := 0
offset := 0
count := int64(total) count := int64(total)
// When FetchRowNumber is used, we only return 1 record // When FetchRowNumber is used, we only return 1 record
@@ -585,54 +622,107 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
// Check if we should use nested processing // Check if we should use nested processing
if h.shouldUseNestedProcessor(v, model) { if h.shouldUseNestedProcessor(v, model) {
logger.Info("Using nested CUD processor for create operation") logger.Info("Using nested CUD processor for create operation")
result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", v, model, make(map[string]interface{}), tableName) var nestedResult *common.ProcessResult
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: v,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
v = modifiedData
}
originalProcessor := h.nestedProcessor
h.nestedProcessor = common.NewNestedCUDProcessor(tx, h.registry, h)
defer func() {
h.nestedProcessor = originalProcessor
}()
var procErr error
nestedResult, procErr = h.nestedProcessor.ProcessNestedCUD(ctx, "insert", v, model, make(map[string]interface{}), tableName)
return procErr
})
if err != nil { if err != nil {
logger.Error("Error in nested create: %v", err) logger.Error("Error in nested create: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record with nested data", err) h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record with nested data", err)
return return
} }
logger.Info("Successfully created record with nested data, ID: %v", result.ID) logger.Info("Successfully created record with nested data, ID: %v", nestedResult.ID)
// Invalidate cache for this table // Invalidate cache for this table
cacheTags := buildCacheTags(schema, tableName) cacheTags := buildCacheTags(schema, tableName)
if err := invalidateCacheForTags(ctx, cacheTags); err != nil { if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err) logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
} }
h.sendResponse(w, result.Data, nil) h.sendResponse(w, nestedResult.Data, nil)
return return
} }
// Standard processing without nested relations // Standard processing without nested relations
pkName := reflection.GetPrimaryKeyName(model) pkName := reflection.GetPrimaryKeyName(model)
query := h.db.NewInsert().Table(tableName) var responseData interface{} = v
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: v,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
v = modifiedData
}
responseData = v
query := tx.NewInsert().Table(tableName)
for key, value := range v { for key, value := range v {
query = query.Value(key, common.ConvertSliceForBun(value)) query = query.Value(key, common.ConvertSliceForBun(value))
} }
var responseData interface{} = v
if pkName == "" { if pkName == "" {
// No PK on model — insert and return input as-is. // No PK on model — insert and return input as-is.
result, err := query.Exec(ctx) result, err := query.Exec(ctx)
if err != nil { if err != nil {
logger.Error("Error creating record: %v", err) return err
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
return
} }
logger.Info("Successfully created record, rows affected: %d", result.RowsAffected()) logger.Info("Successfully created record, rows affected: %d", result.RowsAffected())
} else { return nil
}
var insertedID interface{} var insertedID interface{}
if err := query.Returning(pkName).Scan(ctx, &insertedID); err != nil { if err := query.Returning(pkName).Scan(ctx, &insertedID); err != nil {
logger.Error("Error creating record: %v", err) return err
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
return
} }
logger.Info("Successfully created record with %s: %v", pkName, insertedID) logger.Info("Successfully created record with %s: %v", pkName, insertedID)
fetchedRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface() fetchedRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
if fetchErr := h.db.NewSelect().Model(fetchedRecord). if fetchErr := tx.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), insertedID). Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), insertedID).
ScanModel(ctx); fetchErr == nil { ScanModel(ctx); fetchErr == nil {
responseData = mergeWithInput(fetchedRecord, v) responseData = mergeWithInput(fetchedRecord, v)
} else { } else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, insertedID, fetchErr) logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, insertedID, fetchErr)
} }
return nil
})
if err != nil {
logger.Error("Error creating record: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
return
} }
// Invalidate cache for this table // Invalidate cache for this table
cacheTags := buildCacheTags(schema, tableName) cacheTags := buildCacheTags(schema, tableName)
@@ -663,6 +753,24 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
}() }()
for _, item := range v { for _, item := range v {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: item,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
item = modifiedData
}
result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", item, model, make(map[string]interface{}), tableName) result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", item, model, make(map[string]interface{}), tableName)
if err != nil { if err != nil {
return fmt.Errorf("failed to process item: %w", err) return fmt.Errorf("failed to process item: %w", err)
@@ -689,10 +797,27 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
// Standard batch insert without nested relations // Standard batch insert without nested relations
pkName := reflection.GetPrimaryKeyName(model) pkName := reflection.GetPrimaryKeyName(model)
modelElemType := reflection.GetPointerElement(reflect.TypeOf(model)) modelElemType := reflection.GetPointerElement(reflect.TypeOf(model))
originals := make([]map[string]interface{}, 0, len(v)) responseItems := make([]interface{}, 0, len(v))
insertedIDs := make([]interface{}, 0, len(v))
err := h.db.RunInTransaction(ctx, func(tx common.Database) error { err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range v { for _, item := range v {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: item,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
item = modifiedData
}
txQuery := tx.NewInsert().Table(tableName) txQuery := tx.NewInsert().Table(tableName)
for key, value := range item { for key, value := range item {
txQuery = txQuery.Value(key, common.ConvertSliceForBun(value)) txQuery = txQuery.Value(key, common.ConvertSliceForBun(value))
@@ -701,16 +826,22 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
if _, err := txQuery.Exec(ctx); err != nil { if _, err := txQuery.Exec(ctx); err != nil {
return err return err
} }
originals = append(originals, item) responseItems = append(responseItems, item)
insertedIDs = append(insertedIDs, nil)
continue continue
} }
var returnedID interface{} var returnedID interface{}
if err := txQuery.Returning(pkName).Scan(ctx, &returnedID); err != nil { if err := txQuery.Returning(pkName).Scan(ctx, &returnedID); err != nil {
return err return err
} }
originals = append(originals, item) fetchedRecord := reflect.New(modelElemType).Interface()
insertedIDs = append(insertedIDs, returnedID) if fetchErr := tx.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), returnedID).
ScanModel(ctx); fetchErr == nil {
responseItems = append(responseItems, mergeWithInput(fetchedRecord, item))
} else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, returnedID, fetchErr)
responseItems = append(responseItems, item)
}
} }
return nil return nil
}) })
@@ -725,23 +856,6 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
if err := invalidateCacheForTags(ctx, cacheTags); err != nil { if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err) logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
} }
// Re-fetch each record after transaction commits; fall back to input on failure.
responseItems := make([]interface{}, 0, len(insertedIDs))
for i, pkVal := range insertedIDs {
if pkVal == nil {
responseItems = append(responseItems, originals[i])
continue
}
fetchedRecord := reflect.New(modelElemType).Interface()
if fetchErr := h.db.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), pkVal).
ScanModel(ctx); fetchErr == nil {
responseItems = append(responseItems, mergeWithInput(fetchedRecord, originals[i]))
} else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, pkVal, fetchErr)
responseItems = append(responseItems, originals[i])
}
}
h.sendResponse(w, responseItems, nil) h.sendResponse(w, responseItems, nil)
case []interface{}: case []interface{}:
@@ -770,6 +884,24 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
for _, item := range v { for _, item := range v {
if itemMap, ok := item.(map[string]interface{}); ok { if itemMap, ok := item.(map[string]interface{}); ok {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
itemMap = modifiedData
}
result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", itemMap, model, make(map[string]interface{}), tableName) result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", itemMap, model, make(map[string]interface{}), tableName)
if err != nil { if err != nil {
return fmt.Errorf("failed to process item: %w", err) return fmt.Errorf("failed to process item: %w", err)
@@ -797,14 +929,32 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
// Standard batch insert without nested relations // Standard batch insert without nested relations
pkName := reflection.GetPrimaryKeyName(model) pkName := reflection.GetPrimaryKeyName(model)
modelElemType := reflection.GetPointerElement(reflect.TypeOf(model)) modelElemType := reflection.GetPointerElement(reflect.TypeOf(model))
originals := make([]map[string]interface{}, 0, len(v)) responseItems := make([]interface{}, 0, len(v))
insertedIDs := make([]interface{}, 0, len(v))
err := h.db.RunInTransaction(ctx, func(tx common.Database) error { err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range v { for _, item := range v {
itemMap, ok := item.(map[string]interface{}) itemMap, ok := item.(map[string]interface{})
if !ok { if !ok {
continue continue
} }
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
itemMap = modifiedData
}
txQuery := tx.NewInsert().Table(tableName) txQuery := tx.NewInsert().Table(tableName)
for key, value := range itemMap { for key, value := range itemMap {
txQuery = txQuery.Value(key, common.ConvertSliceForBun(value)) txQuery = txQuery.Value(key, common.ConvertSliceForBun(value))
@@ -813,16 +963,22 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
if _, err := txQuery.Exec(ctx); err != nil { if _, err := txQuery.Exec(ctx); err != nil {
return err return err
} }
originals = append(originals, itemMap) responseItems = append(responseItems, itemMap)
insertedIDs = append(insertedIDs, nil)
continue continue
} }
var returnedID interface{} var returnedID interface{}
if err := txQuery.Returning(pkName).Scan(ctx, &returnedID); err != nil { if err := txQuery.Returning(pkName).Scan(ctx, &returnedID); err != nil {
return err return err
} }
originals = append(originals, itemMap) fetchedRecord := reflect.New(modelElemType).Interface()
insertedIDs = append(insertedIDs, returnedID) if fetchErr := tx.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), returnedID).
ScanModel(ctx); fetchErr == nil {
responseItems = append(responseItems, mergeWithInput(fetchedRecord, itemMap))
} else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, returnedID, fetchErr)
responseItems = append(responseItems, itemMap)
}
} }
return nil return nil
}) })
@@ -837,23 +993,6 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
if err := invalidateCacheForTags(ctx, cacheTags); err != nil { if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err) logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
} }
// Re-fetch each record after transaction commits; fall back to input on failure.
responseItems := make([]interface{}, 0, len(insertedIDs))
for i, pkVal := range insertedIDs {
if pkVal == nil {
responseItems = append(responseItems, originals[i])
continue
}
fetchedRecord := reflect.New(modelElemType).Interface()
if fetchErr := h.db.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), pkVal).
ScanModel(ctx); fetchErr == nil {
responseItems = append(responseItems, mergeWithInput(fetchedRecord, originals[i]))
} else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, pkVal, fetchErr)
responseItems = append(responseItems, originals[i])
}
}
h.sendResponse(w, responseItems, nil) h.sendResponse(w, responseItems, nil)
default: default:
@@ -917,47 +1056,18 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// Get the primary key name // Get the primary key name
pkName := reflection.GetPrimaryKeyName(model) pkName := reflection.GetPrimaryKeyName(model)
if targetID == nil {
logger.Error("Update request for %s.%s has no ID (not in URL, request ID, or data)", schema, entity)
h.sendError(w, http.StatusBadRequest, "missing_id", "No ID provided for update", nil)
return
}
// Wrap in transaction to ensure BeforeUpdate hook is inside transaction // Wrap in transaction to ensure BeforeUpdate hook is inside transaction
err := h.db.RunInTransaction(ctx, func(tx common.Database) error { err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
// First, read the existing record from the database // Execute BeforeUpdate hooks inside transaction, before any queries run.
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface() // BeforeUpdate hooks may set session-scoped RLS GUCs (via SET LOCAL);
selectQuery := tx.NewSelect().Model(existingRecord).Column(reflection.GetSQLModelColumns(model)...) // they must run before the existence-check select so that select is
// also subject to RLS on this connection/transaction.
// Apply conditions to select
if urlID != "" {
logger.Debug("Updating by URL ID: %s", urlID)
selectQuery = selectQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), urlID)
} else if reqID != nil {
switch id := reqID.(type) {
case string:
logger.Debug("Updating by request ID: %s", id)
selectQuery = selectQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
case []string:
if len(id) > 0 {
logger.Debug("Updating by multiple IDs: %v", id)
selectQuery = selectQuery.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(pkName)), id)
}
}
}
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("no records found to update")
}
return fmt.Errorf("error fetching existing record: %w", err)
}
// Convert existing record to map
existingMap := make(map[string]interface{})
jsonData, err := json.Marshal(existingRecord)
if err != nil {
return fmt.Errorf("error marshaling existing record: %w", err)
}
if err := json.Unmarshal(jsonData, &existingMap); err != nil {
return fmt.Errorf("error unmarshaling existing record: %w", err)
}
// Execute BeforeUpdate hooks inside transaction
hookCtx := &HookContext{ hookCtx := &HookContext{
Context: ctx, Context: ctx,
Handler: h, Handler: h,
@@ -980,6 +1090,43 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
updates = modifiedData updates = modifiedData
} }
// 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)...)
// Apply conditions to select, based on the resolved target ID
// (URL ID, request ID, or the "id" field embedded in the data payload).
switch id := targetID.(type) {
case string:
logger.Debug("Updating by ID: %s", id)
selectQuery = selectQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
case []string:
if len(id) > 0 {
logger.Debug("Updating by multiple IDs: %v", id)
selectQuery = selectQuery.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(pkName)), id)
}
default:
logger.Debug("Updating by ID: %v", id)
selectQuery = selectQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
}
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("no records found to update")
}
return fmt.Errorf("error fetching existing record: %w", err)
}
// Convert existing record to map
existingMap := make(map[string]interface{})
jsonData, err := json.Marshal(existingRecord)
if err != nil {
return fmt.Errorf("error marshaling existing record: %w", err)
}
if err := json.Unmarshal(jsonData, &existingMap); err != nil {
return fmt.Errorf("error unmarshaling existing record: %w", err)
}
// Merge only non-null and non-empty values from the incoming request into the existing record // Merge only non-null and non-empty values from the incoming request into the existing record
for key, newValue := range updates { for key, newValue := range updates {
// Skip if the value is nil // Skip if the value is nil
@@ -999,16 +1146,16 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// Build update query with merged data // Build update query with merged data
query := tx.NewUpdate().Table(tableName).SetMap(existingMap) query := tx.NewUpdate().Table(tableName).SetMap(existingMap)
// Apply conditions // Apply conditions, using the same target ID resolved for the select above
if urlID != "" { switch id := targetID.(type) {
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), urlID)
} else if reqID != nil {
switch id := reqID.(type) {
case string: case string:
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id) query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
case []string: case []string:
if len(id) > 0 {
query = query.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(pkName)), id) query = query.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(pkName)), id)
} }
default:
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
} }
result, err := query.Exec(ctx) result, err := query.Exec(ctx)
+123 -78
View File
@@ -327,26 +327,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
options.SingleRecordAsObject = false options.SingleRecordAsObject = false
} }
// Execute BeforeRead hooks
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
TableName: tableName,
Model: model,
Options: options,
ID: id,
Writer: w,
Tx: h.db,
}
if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil {
logger.Error("BeforeRead hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
}
// Validate and unwrap model type to get base struct // Validate and unwrap model type to get base struct
modelType := reflect.TypeOf(model) modelType := reflect.TypeOf(model)
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) { for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
@@ -364,9 +344,43 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Info("Reading records from %s.%s", schema, entity) logger.Info("Reading records from %s.%s", schema, entity)
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
TableName: tableName,
Model: model,
Options: options,
ID: id,
Writer: w,
}
// Everything below runs inside a single transaction so that the BeforeRead/BeforeScan
// hooks (which may set session-scoped RLS GUCs via SET LOCAL) execute on the same
// physical connection as the queries they are meant to protect. Under connection
// pooling, firing a hook against h.db and then querying against h.db again may hand
// out two different connections, silently bypassing RLS.
var (
total int
fetchedRowNumber *int64
statusCode int
errCode string
errMsg string
)
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx.Tx = tx
if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil {
statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed"
return err
}
options = hookCtx.Options
// Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count() // Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count()
// Bun's Model() accepts both single pointers and slice pointers // Bun's Model() accepts both single pointers and slice pointers
query := h.db.NewSelect().Model(modelPtr) query := tx.NewSelect().Model(modelPtr)
// Only set Table() if the model doesn't provide a table name via the underlying type // Only set Table() if the model doesn't provide a table name via the underlying type
// Create a temporary instance to check for TableNameProvider // Create a temporary instance to check for TableNameProvider
@@ -483,9 +497,9 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
fixedWhere, err := common.ValidateAndFixPreloadWhere(preload.Where, preload.Relation) fixedWhere, err := common.ValidateAndFixPreloadWhere(preload.Where, preload.Relation)
if err != nil { if err != nil {
logger.Error("Invalid preload WHERE clause for relation '%s': %v", preload.Relation, err) logger.Error("Invalid preload WHERE clause for relation '%s': %v", preload.Relation, err)
h.sendError(w, http.StatusBadRequest, "invalid_preload_where", statusCode, errCode, errMsg = http.StatusBadRequest, "invalid_preload_where",
fmt.Sprintf("Invalid preload WHERE clause for relation '%s'", preload.Relation), err) fmt.Sprintf("Invalid preload WHERE clause for relation '%s'", preload.Relation)
return return err
} }
preload.Where = fixedWhere preload.Where = fixedWhere
} }
@@ -602,7 +616,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// Handle FetchRowNumber before applying ID filter // Handle FetchRowNumber before applying ID filter
// This must happen before the query to get the row position, then filter by PK // This must happen before the query to get the row position, then filter by PK
var fetchedRowNumber *int64
var fetchRowNumberPKValue string var fetchRowNumberPKValue string
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" { if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
pkName := reflection.GetPrimaryKeyName(model) pkName := reflection.GetPrimaryKeyName(model)
@@ -610,11 +623,11 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Debug("FetchRowNumber: Fetching row number for PK %s = %s", pkName, fetchRowNumberPKValue) logger.Debug("FetchRowNumber: Fetching row number for PK %s = %s", pkName, fetchRowNumberPKValue)
rowNum, err := h.FetchRowNumber(ctx, tableName, pkName, fetchRowNumberPKValue, options, model) rowNum, err := h.FetchRowNumber(ctx, tx, tableName, pkName, fetchRowNumberPKValue, options, model)
if err != nil { if err != nil {
logger.Error("Failed to fetch row number: %v", err) logger.Error("Failed to fetch row number: %v", err)
h.sendError(w, http.StatusBadRequest, "fetch_rownumber_error", "Failed to fetch row number", err) statusCode, errCode, errMsg = http.StatusBadRequest, "fetch_rownumber_error", "Failed to fetch row number"
return return err
} }
fetchedRowNumber = &rowNum fetchedRowNumber = &rowNum
@@ -655,7 +668,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
} }
// Get total count before pagination (unless skip count is requested) // Get total count before pagination (unless skip count is requested)
var total int
if !options.SkipCount { if !options.SkipCount {
// Try to get from cache first (unless SkipCache is true) // Try to get from cache first (unless SkipCache is true)
var cachedTotalData *cachedTotal var cachedTotalData *cachedTotal
@@ -703,8 +715,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
count, err := query.Count(ctx) count, err := query.Count(ctx)
if err != nil { if err != nil {
logger.Error("Error counting records: %v", err) logger.Error("Error counting records: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error counting records", err) statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error counting records"
return return err
} }
total = count total = count
logger.Debug("Total records (from query): %d", total) logger.Debug("Total records (from query): %d", total)
@@ -764,8 +776,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
cursorFilter, err := options.GetCursorFilter(tableName, pkName, modelColumns, expandJoins) cursorFilter, err := options.GetCursorFilter(tableName, pkName, modelColumns, expandJoins)
if err != nil { if err != nil {
logger.Error("Error building cursor filter: %v", err) logger.Error("Error building cursor filter: %v", err)
h.sendError(w, http.StatusBadRequest, "cursor_error", "Invalid cursor pagination", err) statusCode, errCode, errMsg = http.StatusBadRequest, "cursor_error", "Invalid cursor pagination"
return return err
} }
// Apply cursor filter to query // Apply cursor filter to query
@@ -782,8 +794,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
hookCtx.Query = query hookCtx.Query = query
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil { if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err) logger.Error("BeforeScan hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err) statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed"
return return err
} }
// Use potentially modified query from hook context // Use potentially modified query from hook context
@@ -794,7 +806,18 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// Execute query - modelPtr was already created earlier // Execute query - modelPtr was already created earlier
if err := query.ScanModel(ctx); err != nil { if err := query.ScanModel(ctx); err != nil {
logger.Error("Error executing query: %v", err) logger.Error("Error executing query: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err) statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
return err
}
return nil
})
if txErr != nil {
if statusCode == 0 {
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
}
h.sendError(w, statusCode, errCode, errMsg, txErr)
return return
} }
@@ -839,7 +862,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Debug("FetchRowNumber: Row number %d set in metadata", *fetchedRowNumber) logger.Debug("FetchRowNumber: Row number %d set in metadata", *fetchedRowNumber)
} }
// Execute AfterRead hooks // Execute AfterRead hooks (runs after the transaction commits, against the pooled db)
hookCtx.Tx = h.db
hookCtx.Result = modelPtr hookCtx.Result = modelPtr
hookCtx.Error = nil hookCtx.Error = nil
@@ -1133,7 +1157,6 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
logger.Info("Creating record in %s.%s", schema, entity) logger.Info("Creating record in %s.%s", schema, entity)
// Execute BeforeCreate hooks
hookCtx := &HookContext{ hookCtx := &HookContext{
Context: ctx, Context: ctx,
Handler: h, Handler: h,
@@ -1144,28 +1167,38 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Options: options, Options: options,
Data: data, Data: data,
Writer: w, Writer: w,
Tx: h.db,
} }
// Everything below (including the BeforeCreate hook) runs inside a single
// transaction so that session-scoped RLS GUCs set by the hook execute on the
// same physical connection as the inserts they are meant to protect.
var (
dataSlice []interface{}
originalDataMaps []map[string]interface{}
statusCode int
errCode string
errMsg string
)
// Process all items in a transaction
results := make([]interface{}, 0)
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx.Tx = tx
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil { if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
logger.Error("BeforeCreate hook failed: %v", err) statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed"
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err) return err
return
} }
// Use potentially modified data from hook context // Use potentially modified data from hook context
data = hookCtx.Data data = hookCtx.Data
// Normalize data to slice for unified processing // Normalize data to slice for unified processing
dataSlice := h.normalizeToSlice(data) dataSlice = h.normalizeToSlice(data)
logger.Debug("Processing %d item(s) for creation", len(dataSlice)) logger.Debug("Processing %d item(s) for creation", len(dataSlice))
// Store original data maps for merging later // Store original data maps for merging later
originalDataMaps := make([]map[string]interface{}, 0, len(dataSlice)) originalDataMaps = make([]map[string]interface{}, 0, len(dataSlice))
// Process all items in a transaction
results := make([]interface{}, 0, len(dataSlice))
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
// Create temporary nested processor with transaction // Create temporary nested processor with transaction
txNestedProcessor := common.NewNestedCUDProcessor(tx, h.registry, h) txNestedProcessor := common.NewNestedCUDProcessor(tx, h.registry, h)
@@ -1266,9 +1299,12 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
return nil return nil
}) })
if err != nil { if txErr != nil {
logger.Error("Error creating records: %v", err) if statusCode == 0 {
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating records", err) statusCode, errCode, errMsg = http.StatusInternalServerError, "create_error", "Error creating records"
}
logger.Error("Error creating records: %v", txErr)
h.sendError(w, statusCode, errCode, errMsg, txErr)
return return
} }
@@ -1284,7 +1320,10 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
} }
} }
// Execute AfterCreate hooks // Execute AfterCreate hooks (runs after the transaction commits, against the
// pooled db — hookCtx.Tx was pointed at the now-closed transaction inside the
// RunInTransaction closure above and must not be reused here).
hookCtx.Tx = h.db
var responseData interface{} var responseData interface{}
if len(mergedResults) == 1 { if len(mergedResults) == 1 {
responseData = mergedResults[0] responseData = mergedResults[0]
@@ -1366,7 +1405,34 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// Create temporary nested processor with transaction // Create temporary nested processor with transaction
txNestedProcessor := common.NewNestedCUDProcessor(tx, h.registry, h) txNestedProcessor := common.NewNestedCUDProcessor(tx, h.registry, h)
// First, read the existing record from the database // Execute BeforeUpdate hooks inside transaction, before any queries run.
// BeforeUpdate hooks may set session-scoped RLS GUCs (via SET LOCAL);
// they must run before the existence-check select so that select is
// also subject to RLS on this connection/transaction.
hookCtx = &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
TableName: tableName,
Tx: tx,
Model: model,
Options: options,
ID: id,
Data: dataMap,
Writer: w,
}
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
return fmt.Errorf("BeforeUpdate hook failed: %w", err)
}
// Use potentially modified data from hook context
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
dataMap = modifiedData
}
// Now read the existing record from the database
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface() existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := tx.NewSelect().Model(existingRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID) selectQuery := tx.NewSelect().Model(existingRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
if err := selectQuery.ScanModel(ctx); err != nil { if err := selectQuery.ScanModel(ctx); err != nil {
@@ -1398,30 +1464,6 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
nestedRelations = relations nestedRelations = relations
} }
// Execute BeforeUpdate hooks inside transaction
hookCtx = &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
TableName: tableName,
Tx: tx,
Model: model,
Options: options,
ID: id,
Data: dataMap,
Writer: w,
}
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
return fmt.Errorf("BeforeUpdate hook failed: %w", err)
}
// Use potentially modified data from hook context
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
dataMap = modifiedData
}
// Merge only non-null and non-empty values from the incoming request into the existing record // Merge only non-null and non-empty values from the incoming request into the existing record
for key, newValue := range dataMap { for key, newValue := range dataMap {
// Skip if the value is nil // Skip if the value is nil
@@ -1498,6 +1540,9 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// Execute BeforeScan hooks so row security is re-applied to the post-update // Execute BeforeScan hooks so row security is re-applied to the post-update
// re-fetch, same as it is for the initial read and the update query itself. // re-fetch, same as it is for the initial read and the update query itself.
// Without this, the re-fetch can return a row the caller isn't authorized to see. // Without this, the re-fetch can return a row the caller isn't authorized to see.
// The transaction has already committed by this point, so hooks must use the
// pooled connection rather than the now-dead tx.
hookCtx.Tx = h.db
hookCtx.Query = selectQuery hookCtx.Query = selectQuery
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil { if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err) logger.Error("BeforeScan hook failed: %v", err)
@@ -2825,7 +2870,7 @@ func (h *Handler) sendError(w common.ResponseWriter, statusCode int, code, messa
// FetchRowNumber calculates the row number of a specific record based on sorting and filtering // FetchRowNumber calculates the row number of a specific record based on sorting and filtering
// Returns the 1-based row number of the record with the given primary key value // Returns the 1-based row number of the record with the given primary key value
func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName string, pkValue string, options ExtendedRequestOptions, model any) (int64, error) { func (h *Handler) FetchRowNumber(ctx context.Context, db common.Database, tableName string, pkName string, pkValue string, options ExtendedRequestOptions, model any) (int64, error) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
logger.Error("Panic during FetchRowNumber: %v", r) logger.Error("Panic during FetchRowNumber: %v", r)
@@ -2909,7 +2954,7 @@ func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName s
RN int64 `bun:"rn"` RN int64 `bun:"rn"`
} }
logger.Debug("[FetchRowNumber] BEFORE Query call - about to execute raw query") logger.Debug("[FetchRowNumber] BEFORE Query call - about to execute raw query")
err := h.db.Query(ctx, &result, queryStr, pkValue) err := db.Query(ctx, &result, queryStr, pkValue)
logger.Debug("[FetchRowNumber] AFTER Query call - query completed with %d results, err: %v", len(result), err) logger.Debug("[FetchRowNumber] AFTER Query call - query completed with %d results, err: %v", len(result), err)
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to fetch row number: %w", err) return 0, fmt.Errorf("failed to fetch row number: %w", err)