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.
This commit is contained in:
Hein
2026-07-24 12:40:27 +02:00
parent a85e572732
commit 47708fc87a
2 changed files with 910 additions and 732 deletions
+442 -303
View File
@@ -259,278 +259,315 @@ 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()
// Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count() // Everything below runs inside a single transaction so that the BeforeRead
// Bun's Model() accepts both single pointers and slice pointers // hook (which sets session-scoped RLS GUCs via SET LOCAL) executes on the
query := h.db.NewSelect().Model(modelPtr) // 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
)
// Only set Table() if the model doesn't provide a table name via the underlying type txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
// Create a temporary instance to check for TableNameProvider hookCtx := &HookContext{
tempInstance := reflect.New(modelType).Interface() Context: ctx,
if provider, ok := tempInstance.(common.TableNameProvider); !ok || provider.TableName() == "" { Handler: h,
query = query.Table(tableName) Schema: schema,
} Entity: entity,
Model: model,
if len(options.Columns) == 0 && (len(options.ComputedColumns) > 0) { Options: options,
logger.Debug("Populating options.Columns with all model columns since computed columns are additions") ID: id,
options.Columns = reflection.GetSQLModelColumns(model) Writer: w,
} Tx: tx,
// Apply column selection
if len(options.Columns) > 0 {
logger.Debug("Selecting columns: %v", options.Columns)
for _, col := range options.Columns {
query = query.Column(reflection.ExtractSourceColumn(col))
} }
} if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil {
statusCode, errCode, errMsg = http.StatusInternalServerError, "hook_error", "BeforeRead hook failed"
if len(options.ComputedColumns) > 0 { return err
for _, cu := range options.ComputedColumns {
logger.Debug("Applying computed column: %s", cu.Name)
query = query.ColumnExpr(fmt.Sprintf("(%s) AS %s", cu.Expression, cu.Name))
} }
} options = hookCtx.Options
// Apply preloading // Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count()
if len(options.Preload) > 0 { // Bun's Model() accepts both single pointers and slice pointers
var err error query := tx.NewSelect().Model(modelPtr)
query, err = h.applyPreloads(model, query, options.Preload)
if err != nil {
logger.Error("Failed to apply preloads: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_preload", "Failed to apply preloads", err)
return
}
}
// Apply filters with proper grouping for OR logic // Only set Table() if the model doesn't provide a table name via the underlying type
query = h.applyFilters(query, options.Filters) // Create a temporary instance to check for TableNameProvider
tempInstance := reflect.New(modelType).Interface()
// Apply custom operators if provider, ok := tempInstance.(common.TableNameProvider); !ok || provider.TableName() == "" {
for _, customOp := range options.CustomOperators { query = query.Table(tableName)
logger.Debug("Applying custom operator: %s - %s", customOp.Name, customOp.SQL)
query = query.Where(customOp.SQL)
}
// Apply sorting
for _, sort := range options.Sort {
direction := "ASC"
if strings.EqualFold(sort.Direction, "desc") {
direction = "DESC"
}
logger.Debug("Applying sort: %s %s", sort.Column, direction)
query = query.Order(fmt.Sprintf("%s %s", sort.Column, direction))
}
// Apply cursor-based pagination
if len(options.CursorForward) > 0 || len(options.CursorBackward) > 0 {
logger.Debug("Applying cursor pagination")
// Get primary key name
pkName := reflection.GetPrimaryKeyName(model)
// Extract model columns for validation
modelColumns := reflection.GetModelColumns(model)
// Default sort to primary key when none provided
if len(options.Sort) == 0 {
options.Sort = []common.SortOption{{Column: pkName, Direction: "ASC"}}
} }
// Get cursor filter SQL (expandJoins is empty for resolvespec — no custom SQL join support yet) if len(options.Columns) == 0 && (len(options.ComputedColumns) > 0) {
cursorFilter, err := GetCursorFilter(tableName, pkName, modelColumns, options, nil) logger.Debug("Populating options.Columns with all model columns since computed columns are additions")
if err != nil { options.Columns = reflection.GetSQLModelColumns(model)
logger.Error("Error building cursor filter: %v", err)
h.sendError(w, http.StatusBadRequest, "cursor_error", "Invalid cursor pagination", err)
return
} }
// Apply cursor filter to query // Apply column selection
if cursorFilter != "" { if len(options.Columns) > 0 {
logger.Debug("Applying cursor filter: %s", cursorFilter) logger.Debug("Selecting columns: %v", options.Columns)
sanitizedCursor := common.SanitizeWhereClause(cursorFilter, reflection.ExtractTableNameOnly(tableName), &options) for _, col := range options.Columns {
// Ensure outer parentheses to prevent OR logic from escaping query = query.Column(reflection.ExtractSourceColumn(col))
sanitizedCursor = common.EnsureOuterParentheses(sanitizedCursor)
if sanitizedCursor != "" {
query = query.Where(sanitizedCursor)
} }
} }
}
// Get total count before pagination if len(options.ComputedColumns) > 0 {
var total int for _, cu := range options.ComputedColumns {
logger.Debug("Applying computed column: %s", cu.Name)
// Try to get from cache first query = query.ColumnExpr(fmt.Sprintf("(%s) AS %s", cu.Expression, cu.Name))
// Use extended cache key if cursors are present
var cacheKeyHash string
if len(options.CursorForward) > 0 || len(options.CursorBackward) > 0 {
cacheKeyHash = buildExtendedQueryCacheKey(
tableName,
options.Filters,
options.Sort,
"", // No custom SQL WHERE in resolvespec
"", // No custom SQL OR in resolvespec
options.CursorForward,
options.CursorBackward,
)
} else {
cacheKeyHash = buildQueryCacheKey(
tableName,
options.Filters,
options.Sort,
"", // No custom SQL WHERE in resolvespec
"", // No custom SQL OR in resolvespec
)
}
cacheKey := getQueryTotalCacheKey(cacheKeyHash)
// Try to retrieve from cache
var cachedTotal cachedTotal
err := cache.GetDefaultCache().Get(ctx, cacheKey, &cachedTotal)
if err == nil {
total = cachedTotal.Total
logger.Debug("Total records (from cache): %d", total)
} else {
// Cache miss - execute count query
logger.Debug("Cache miss for query total")
count, err := query.Count(ctx)
if err != nil {
logger.Error("Error counting records: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error counting records", err)
return
}
total = count
logger.Debug("Total records (from query): %d", total)
// Store in cache with schema and table tags
cacheTTL := time.Minute * 2 // Default 2 minutes TTL
if err := setQueryTotalCache(ctx, cacheKey, total, schema, tableName, cacheTTL); err != nil {
logger.Warn("Failed to cache query total: %v", err)
// Don't fail the request if caching fails
} else {
logger.Debug("Cached query total with key: %s", cacheKey)
}
}
// Handle FetchRowNumber if requested
var rowNumber *int64
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
logger.Debug("Fetching row number for ID: %s", *options.FetchRowNumber)
pkName := reflection.GetPrimaryKeyName(model)
// Build ROW_NUMBER window function SQL
rowNumberSQL := "ROW_NUMBER() OVER ("
if len(options.Sort) > 0 {
rowNumberSQL += "ORDER BY "
for i, sort := range options.Sort {
if i > 0 {
rowNumberSQL += ", "
}
direction := "ASC"
if strings.EqualFold(sort.Direction, "desc") {
direction = "DESC"
}
rowNumberSQL += fmt.Sprintf("%s %s", sort.Column, direction)
} }
} }
rowNumberSQL += ")"
// Create a query to fetch the row number using a subquery approach // Apply preloading
// We'll select the PK and row_number, then filter by the target ID if len(options.Preload) > 0 {
type RowNumResult struct { var err error
RowNum int64 `bun:"row_num"` query, err = h.applyPreloads(model, query, options.Preload)
if err != nil {
logger.Error("Failed to apply preloads: %v", err)
statusCode, errCode, errMsg = http.StatusBadRequest, "invalid_preload", "Failed to apply preloads"
return err
}
} }
rowNumQuery := h.db.NewSelect().Table(tableName). // Apply filters with proper grouping for OR logic
ColumnExpr(fmt.Sprintf("%s AS row_num", rowNumberSQL)). query = h.applyFilters(query, options.Filters)
Column(pkName)
// Apply the same filters as the main query
for _, filter := range options.Filters {
rowNumQuery = h.applyFilter(rowNumQuery, filter)
}
// Apply custom operators // Apply custom operators
for _, customOp := range options.CustomOperators { for _, customOp := range options.CustomOperators {
rowNumQuery = rowNumQuery.Where(customOp.SQL) logger.Debug("Applying custom operator: %s - %s", customOp.Name, customOp.SQL)
query = query.Where(customOp.SQL)
} }
// Filter for the specific ID we want the row number for // Apply sorting
rowNumQuery = rowNumQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), *options.FetchRowNumber) for _, sort := range options.Sort {
direction := "ASC"
// Execute query to get row number if strings.EqualFold(sort.Direction, "desc") {
var result RowNumResult direction = "DESC"
if err := rowNumQuery.Scan(ctx, &result); err != nil {
if err == sql.ErrNoRows {
// Build filter description for error message
filterInfo := fmt.Sprintf("filters: %d", len(options.Filters))
if len(options.CustomOperators) > 0 {
customOps := make([]string, 0, len(options.CustomOperators))
for _, op := range options.CustomOperators {
customOps = append(customOps, op.SQL)
}
filterInfo += fmt.Sprintf(", custom operators: [%s]", strings.Join(customOps, "; "))
}
logger.Warn("No row found for primary key %s=%s with %s", pkName, *options.FetchRowNumber, filterInfo)
} else {
logger.Warn("Error fetching row number: %v", err)
} }
logger.Debug("Applying sort: %s %s", sort.Column, direction)
query = query.Order(fmt.Sprintf("%s %s", sort.Column, direction))
}
// Apply cursor-based pagination
if len(options.CursorForward) > 0 || len(options.CursorBackward) > 0 {
logger.Debug("Applying cursor pagination")
// Get primary key name
pkName := reflection.GetPrimaryKeyName(model)
// Extract model columns for validation
modelColumns := reflection.GetModelColumns(model)
// Default sort to primary key when none provided
if len(options.Sort) == 0 {
options.Sort = []common.SortOption{{Column: pkName, Direction: "ASC"}}
}
// Get cursor filter SQL (expandJoins is empty for resolvespec — no custom SQL join support yet)
cursorFilter, err := GetCursorFilter(tableName, pkName, modelColumns, options, nil)
if err != nil {
logger.Error("Error building cursor filter: %v", err)
statusCode, errCode, errMsg = http.StatusBadRequest, "cursor_error", "Invalid cursor pagination"
return err
}
// Apply cursor filter to query
if cursorFilter != "" {
logger.Debug("Applying cursor filter: %s", cursorFilter)
sanitizedCursor := common.SanitizeWhereClause(cursorFilter, reflection.ExtractTableNameOnly(tableName), &options)
// Ensure outer parentheses to prevent OR logic from escaping
sanitizedCursor = common.EnsureOuterParentheses(sanitizedCursor)
if sanitizedCursor != "" {
query = query.Where(sanitizedCursor)
}
}
}
// Try to get from cache first
// Use extended cache key if cursors are present
var cacheKeyHash string
if len(options.CursorForward) > 0 || len(options.CursorBackward) > 0 {
cacheKeyHash = buildExtendedQueryCacheKey(
tableName,
options.Filters,
options.Sort,
"", // No custom SQL WHERE in resolvespec
"", // No custom SQL OR in resolvespec
options.CursorForward,
options.CursorBackward,
)
} else { } else {
rowNumber = &result.RowNum cacheKeyHash = buildQueryCacheKey(
logger.Debug("Found row number: %d", *rowNumber) tableName,
options.Filters,
options.Sort,
"", // No custom SQL WHERE in resolvespec
"", // No custom SQL OR in resolvespec
)
} }
} cacheKey := getQueryTotalCacheKey(cacheKeyHash)
// Apply pagination (skip if FetchRowNumber is set - we want only that record) // Try to retrieve from cache
if options.FetchRowNumber == nil || *options.FetchRowNumber == "" { var cachedTotal cachedTotal
if options.Limit != nil && *options.Limit > 0 { if err := cache.GetDefaultCache().Get(ctx, cacheKey, &cachedTotal); err == nil {
logger.Debug("Applying limit: %d", *options.Limit) total = cachedTotal.Total
query = query.Limit(*options.Limit) logger.Debug("Total records (from cache): %d", total)
}
if options.Offset != nil && *options.Offset > 0 {
logger.Debug("Applying offset: %d", *options.Offset)
query = query.Offset(*options.Offset)
}
}
// Execute query
var result interface{}
if id != "" || (options.FetchRowNumber != nil && *options.FetchRowNumber != "") {
// Single record query - either by URL ID or FetchRowNumber
var targetID string
if id != "" {
targetID = id
logger.Debug("Querying single record with URL ID: %s", id)
} else { } else {
targetID = *options.FetchRowNumber // Cache miss - execute count query
logger.Debug("Querying single record with FetchRowNumber ID: %s", targetID) logger.Debug("Cache miss for query total")
count, err := query.Count(ctx)
if err != nil {
logger.Error("Error counting records: %v", err)
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error counting records"
return err
}
total = count
logger.Debug("Total records (from query): %d", total)
// Store in cache with schema and table tags
cacheTTL := time.Minute * 2 // Default 2 minutes TTL
if err := setQueryTotalCache(ctx, cacheKey, total, schema, tableName, cacheTTL); err != nil {
logger.Warn("Failed to cache query total: %v", err)
// Don't fail the request if caching fails
} else {
logger.Debug("Cached query total with key: %s", cacheKey)
}
} }
// For single record, create a new pointer to the struct type // Handle FetchRowNumber if requested
singleResult := reflect.New(modelType).Interface() if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
pkName := reflection.GetPrimaryKeyName(singleResult) logger.Debug("Fetching row number for ID: %s", *options.FetchRowNumber)
pkName := reflection.GetPrimaryKeyName(model)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID) // Build ROW_NUMBER window function SQL
if err := query.Scan(ctx, singleResult); err != nil { rowNumberSQL := "ROW_NUMBER() OVER ("
logger.Error("Error querying record: %v", err) if len(options.Sort) > 0 {
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err) rowNumberSQL += "ORDER BY "
return for i, sort := range options.Sort {
if i > 0 {
rowNumberSQL += ", "
}
direction := "ASC"
if strings.EqualFold(sort.Direction, "desc") {
direction = "DESC"
}
rowNumberSQL += fmt.Sprintf("%s %s", sort.Column, direction)
}
}
rowNumberSQL += ")"
// Create a query to fetch the row number using a subquery approach
// We'll select the PK and row_number, then filter by the target ID
type RowNumResult struct {
RowNum int64 `bun:"row_num"`
}
rowNumQuery := tx.NewSelect().Table(tableName).
ColumnExpr(fmt.Sprintf("%s AS row_num", rowNumberSQL)).
Column(pkName)
// Apply the same filters as the main query
for _, filter := range options.Filters {
rowNumQuery = h.applyFilter(rowNumQuery, filter)
}
// Apply custom operators
for _, customOp := range options.CustomOperators {
rowNumQuery = rowNumQuery.Where(customOp.SQL)
}
// Filter for the specific ID we want the row number for
rowNumQuery = rowNumQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), *options.FetchRowNumber)
// Execute query to get row number
var rnResult RowNumResult
if err := rowNumQuery.Scan(ctx, &rnResult); err != nil {
if err == sql.ErrNoRows {
// Build filter description for error message
filterInfo := fmt.Sprintf("filters: %d", len(options.Filters))
if len(options.CustomOperators) > 0 {
customOps := make([]string, 0, len(options.CustomOperators))
for _, op := range options.CustomOperators {
customOps = append(customOps, op.SQL)
}
filterInfo += fmt.Sprintf(", custom operators: [%s]", strings.Join(customOps, "; "))
}
logger.Warn("No row found for primary key %s=%s with %s", pkName, *options.FetchRowNumber, filterInfo)
} else {
logger.Warn("Error fetching row number: %v", err)
}
} else {
rowNumber = &rnResult.RowNum
logger.Debug("Found row number: %d", *rowNumber)
}
} }
result = singleResult
} else { // Apply pagination (skip if FetchRowNumber is set - we want only that record)
logger.Debug("Querying multiple records") if options.FetchRowNumber == nil || *options.FetchRowNumber == "" {
// Use the modelPtr already created and set on the query if options.Limit != nil && *options.Limit > 0 {
if err := query.Scan(ctx, modelPtr); err != nil { logger.Debug("Applying limit: %d", *options.Limit)
logger.Error("Error querying records: %v", err) query = query.Limit(*options.Limit)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err) }
return if options.Offset != nil && *options.Offset > 0 {
logger.Debug("Applying offset: %d", *options.Offset)
query = query.Offset(*options.Offset)
}
} }
result = reflect.ValueOf(modelPtr).Elem().Interface()
// Execute query
if id != "" || (options.FetchRowNumber != nil && *options.FetchRowNumber != "") {
// Single record query - either by URL ID or FetchRowNumber
var targetID string
if id != "" {
targetID = id
logger.Debug("Querying single record with URL ID: %s", id)
} else {
targetID = *options.FetchRowNumber
logger.Debug("Querying single record with FetchRowNumber ID: %s", targetID)
}
// For single record, create a new pointer to the struct type
singleResult := reflect.New(modelType).Interface()
pkName := reflection.GetPrimaryKeyName(singleResult)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
if err := query.Scan(ctx, singleResult); err != nil {
logger.Error("Error querying record: %v", err)
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
return err
}
result = singleResult
} else {
logger.Debug("Querying multiple records")
// Use the modelPtr already created and set on the query
if err := query.Scan(ctx, modelPtr); err != nil {
logger.Error("Error querying records: %v", err)
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
return err
}
result = reflect.ValueOf(modelPtr).Elem().Interface()
}
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
} }
logger.Info("Successfully retrieved records")
// 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)
for key, value := range v {
query = query.Value(key, common.ConvertSliceForBun(value))
}
var responseData interface{} = v var responseData interface{} = v
if pkName == "" { err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
// No PK on model — insert and return input as-is. hookCtx := &HookContext{
result, err := query.Exec(ctx) Context: ctx,
if err != nil { Handler: h,
logger.Error("Error creating record: %v", err) Schema: schema,
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err) Entity: entity,
return Model: model,
Options: options,
Data: v,
Writer: w,
Tx: tx,
} }
logger.Info("Successfully created record, rows affected: %d", result.RowsAffected()) if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
} else { 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 {
query = query.Value(key, common.ConvertSliceForBun(value))
}
if pkName == "" {
// No PK on model — insert and return input as-is.
result, err := query.Exec(ctx)
if err != nil {
return err
}
logger.Info("Successfully created record, rows affected: %d", result.RowsAffected())
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:
File diff suppressed because it is too large Load Diff