fix: better error detail for failed sql

This commit is contained in:
Hein
2026-05-20 13:06:26 +02:00
parent 0647a88aba
commit eb9f7b8d03
9 changed files with 101 additions and 32 deletions

View File

@@ -1315,6 +1315,7 @@ func (b *BunSelectQuery) Scan(ctx context.Context, dest interface{}) (err error)
if err != nil {
sqlStr := b.query.String()
logger.Error("BunSelectQuery.Scan failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
return err
}
@@ -1371,7 +1372,7 @@ func (b *BunSelectQuery) ScanModel(ctx context.Context) (err error) {
if err != nil {
sqlStr := b.query.String()
logger.Error("BunSelectQuery.ScanModel failed. SQL: %s. Error: %v", sqlStr, err)
return err
return common.WrapSQLError(err, sqlStr)
}
// After main query, load custom preloads using separate queries
@@ -1401,6 +1402,7 @@ func (b *BunSelectQuery) Count(ctx context.Context) (count int, err error) {
if err != nil {
sqlStr := b.query.String()
logger.Error("BunSelectQuery.Count failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
return
}
@@ -1414,6 +1416,7 @@ func (b *BunSelectQuery) Count(ctx context.Context) (count int, err error) {
if err != nil {
sqlStr := countQuery.String()
logger.Error("BunSelectQuery.Count (subquery) failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
return
}
@@ -1431,6 +1434,7 @@ func (b *BunSelectQuery) Exists(ctx context.Context) (exists bool, err error) {
if err != nil {
sqlStr := b.query.String()
logger.Error("BunSelectQuery.Exists failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
return
}
@@ -1619,6 +1623,7 @@ func (b *BunUpdateQuery) Exec(ctx context.Context) (res common.Result, err error
// Log SQL string for debugging
sqlStr := b.query.String()
logger.Error("BunUpdateQuery.Exec failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
recordQueryMetrics(b.metricsEnabled, "UPDATE", b.schema, b.entity, b.tableName, startedAt, err)
return &BunResult{result: result}, err
@@ -1670,6 +1675,7 @@ func (b *BunDeleteQuery) Exec(ctx context.Context) (res common.Result, err error
// Log SQL string for debugging
sqlStr := b.query.String()
logger.Error("BunDeleteQuery.Exec failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
recordQueryMetrics(b.metricsEnabled, "DELETE", b.schema, b.entity, b.tableName, startedAt, err)
return &BunResult{result: result}, err

View File

@@ -583,6 +583,7 @@ func (g *GormSelectQuery) Scan(ctx context.Context, dest interface{}) (err error
return tx.Find(dest)
})
logger.Error("GormSelectQuery.Scan failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
recordQueryMetrics(g.metricsEnabled, "SELECT", g.schema, g.entity, g.tableName, startedAt, err)
return err
@@ -613,6 +614,7 @@ func (g *GormSelectQuery) ScanModel(ctx context.Context) (err error) {
return tx.Find(g.db.Statement.Model)
})
logger.Error("GormSelectQuery.ScanModel failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
recordQueryMetrics(g.metricsEnabled, "SELECT", g.schema, g.entity, g.tableName, startedAt, err)
return err
@@ -642,6 +644,7 @@ func (g *GormSelectQuery) Count(ctx context.Context) (count int, err error) {
return tx.Count(&count64)
})
logger.Error("GormSelectQuery.Count failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
recordQueryMetrics(g.metricsEnabled, "COUNT", g.schema, g.entity, g.tableName, startedAt, err)
return int(count64), err
@@ -671,6 +674,7 @@ func (g *GormSelectQuery) Exists(ctx context.Context) (exists bool, err error) {
return tx.Limit(1).Count(&count)
})
logger.Error("GormSelectQuery.Exists failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
recordQueryMetrics(g.metricsEnabled, "EXISTS", g.schema, g.entity, g.tableName, startedAt, err)
return count > 0, err
@@ -931,6 +935,7 @@ func (g *GormUpdateQuery) Exec(ctx context.Context) (res common.Result, err erro
return tx.Updates(g.updates)
})
logger.Error("GormUpdateQuery.Exec failed. SQL: %s. Error: %v", sqlStr, result.Error)
return &GormResult{result: result}, common.WrapSQLError(result.Error, sqlStr)
}
recordQueryMetrics(g.metricsEnabled, "UPDATE", g.schema, g.entity, g.tableName, startedAt, result.Error)
return &GormResult{result: result}, result.Error
@@ -992,6 +997,7 @@ func (g *GormDeleteQuery) Exec(ctx context.Context) (res common.Result, err erro
return tx.Delete(g.model)
})
logger.Error("GormDeleteQuery.Exec failed. SQL: %s. Error: %v", sqlStr, result.Error)
return &GormResult{result: result}, common.WrapSQLError(result.Error, sqlStr)
}
recordQueryMetrics(g.metricsEnabled, "DELETE", g.schema, g.entity, g.tableName, startedAt, result.Error)
return &GormResult{result: result}, result.Error

View File

@@ -138,7 +138,7 @@ func (p *PgSQLAdapter) Exec(ctx context.Context, query string, args ...interface
if err != nil {
logger.Error("PgSQL Exec failed: %v", err)
recordQueryMetrics(p.metricsEnabled, operation, schema, entity, table, startedAt, err)
return nil, err
return nil, common.WrapSQLError(err, query)
}
recordQueryMetrics(p.metricsEnabled, operation, schema, entity, table, startedAt, nil)
return &PgSQLResult{result: result}, nil
@@ -164,7 +164,7 @@ func (p *PgSQLAdapter) Query(ctx context.Context, dest interface{}, query string
if err != nil {
logger.Error("PgSQL Query failed: %v", err)
recordQueryMetrics(p.metricsEnabled, operation, schema, entity, table, startedAt, err)
return err
return common.WrapSQLError(err, query)
}
defer rows.Close()
@@ -511,7 +511,7 @@ func (p *PgSQLSelectQuery) Scan(ctx context.Context, dest interface{}) (err erro
if err != nil {
logger.Error("PgSQL SELECT failed: %v", err)
recordQueryMetrics(p.metricsEnabled, "SELECT", p.schema, p.entity, p.tableName, startedAt, err)
return err
return common.WrapSQLError(err, query)
}
defer rows.Close()
@@ -534,8 +534,8 @@ func (p *PgSQLSelectQuery) ScanModel(ctx context.Context) error {
return p.Scan(ctx, p.model)
}
// countInternal executes the COUNT query and returns the result without recording metrics.
func (p *PgSQLSelectQuery) countInternal(ctx context.Context) (int, error) {
// countInternal executes the COUNT query and returns the result and the SQL string without recording metrics.
func (p *PgSQLSelectQuery) countInternal(ctx context.Context) (rowCount int, querySQL string, retErr error) {
var sb strings.Builder
sb.WriteString("SELECT COUNT(*) FROM ")
sb.WriteString(p.tableName)
@@ -571,9 +571,9 @@ func (p *PgSQLSelectQuery) countInternal(ctx context.Context) (int, error) {
var count int
if err := row.Scan(&count); err != nil {
return 0, err
return 0, query, err
}
return count, nil
return count, query, nil
}
func (p *PgSQLSelectQuery) Count(ctx context.Context) (count int, err error) {
@@ -584,9 +584,11 @@ func (p *PgSQLSelectQuery) Count(ctx context.Context) (count int, err error) {
}
}()
startedAt := time.Now()
count, err = p.countInternal(ctx)
var sqlStr string
count, sqlStr, err = p.countInternal(ctx)
if err != nil {
logger.Error("PgSQL COUNT failed: %v", err)
err = common.WrapSQLError(err, sqlStr)
}
recordQueryMetrics(p.metricsEnabled, "COUNT", p.schema, p.entity, p.tableName, startedAt, err)
return count, err
@@ -600,9 +602,11 @@ func (p *PgSQLSelectQuery) Exists(ctx context.Context) (exists bool, err error)
}
}()
startedAt := time.Now()
count, err := p.countInternal(ctx)
var sqlStr string
count, sqlStr, err := p.countInternal(ctx)
if err != nil {
logger.Error("PgSQL EXISTS failed: %v", err)
err = common.WrapSQLError(err, sqlStr)
}
recordQueryMetrics(p.metricsEnabled, "EXISTS", p.schema, p.entity, p.tableName, startedAt, err)
return count > 0, err
@@ -702,7 +706,7 @@ func (p *PgSQLInsertQuery) Exec(ctx context.Context) (res common.Result, err err
if err != nil {
logger.Error("PgSQL INSERT failed: %v", err)
return nil, err
return nil, common.WrapSQLError(err, query)
}
return &PgSQLResult{result: result}, nil
@@ -750,7 +754,10 @@ func (p *PgSQLInsertQuery) Scan(ctx context.Context, dest interface{}) (err erro
row = p.db.QueryRowContext(ctx, query, args...)
}
return row.Scan(dest)
if err := row.Scan(dest); err != nil {
return common.WrapSQLError(err, query)
}
return nil
}
// PgSQLUpdateQuery implements UpdateQuery for PostgreSQL
@@ -929,7 +936,7 @@ func (p *PgSQLUpdateQuery) Exec(ctx context.Context) (res common.Result, err err
if err != nil {
logger.Error("PgSQL UPDATE failed: %v", err)
return nil, err
return nil, common.WrapSQLError(err, query)
}
return &PgSQLResult{result: result}, nil
@@ -1007,7 +1014,7 @@ func (p *PgSQLDeleteQuery) Exec(ctx context.Context) (res common.Result, err err
if err != nil {
logger.Error("PgSQL DELETE failed: %v", err)
return nil, err
return nil, common.WrapSQLError(err, query)
}
return &PgSQLResult{result: result}, nil
@@ -1088,7 +1095,7 @@ func (p *PgSQLTxAdapter) Exec(ctx context.Context, query string, args ...interfa
if err != nil {
logger.Error("PgSQL Tx Exec failed: %v", err)
recordQueryMetrics(p.metricsEnabled, operation, schema, entity, table, startedAt, err)
return nil, err
return nil, common.WrapSQLError(err, query)
}
recordQueryMetrics(p.metricsEnabled, operation, schema, entity, table, startedAt, nil)
return &PgSQLResult{result: result}, nil
@@ -1102,7 +1109,7 @@ func (p *PgSQLTxAdapter) Query(ctx context.Context, dest interface{}, query stri
if err != nil {
logger.Error("PgSQL Tx Query failed: %v", err)
recordQueryMetrics(p.metricsEnabled, operation, schema, entity, table, startedAt, err)
return err
return common.WrapSQLError(err, query)
}
defer rows.Close()

View File

@@ -1,5 +1,23 @@
package common
// SQLError wraps a database error together with the SQL that caused it,
// so callers can surface the query in API error responses for easier debugging.
type SQLError struct {
Err error
SQL string
}
func (e *SQLError) Error() string { return e.Err.Error() }
func (e *SQLError) Unwrap() error { return e.Err }
// WrapSQLError wraps err with the given SQL. If err is nil it returns nil.
func WrapSQLError(err error, sql string) error {
if err == nil {
return nil
}
return &SQLError{Err: err, SQL: sql}
}
type RequestBody struct {
Operation string `json:"operation"`
Data interface{} `json:"data"`
@@ -104,6 +122,7 @@ type APIError struct {
Message string `json:"message"`
Details interface{} `json:"details,omitempty"`
Detail string `json:"detail,omitempty"`
SQL string `json:"sql,omitempty"`
}
type Column struct {

View File

@@ -3,6 +3,7 @@ package funcspec
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -1071,6 +1072,10 @@ func sendError(w http.ResponseWriter, status int, code, message string, err erro
}
if err != nil {
errObj.Detail = err.Error()
var sqlErr *common.SQLError
if errors.As(err, &sqlErr) {
errObj.SQL = sqlErr.SQL
}
}
data, _ := json.Marshal(map[string]interface{}{

View File

@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
@@ -1757,18 +1758,21 @@ func (h *Handler) sendResponse(w common.ResponseWriter, data interface{}, metada
}
func (h *Handler) sendError(w common.ResponseWriter, status int, code, message string, details interface{}) {
w.SetHeader("Content-Type", "application/json")
w.WriteHeader(status)
err := w.WriteJSON(common.Response{
Success: false,
Error: &common.APIError{
apiErr := &common.APIError{
Code: code,
Message: message,
Details: details,
Detail: fmt.Sprintf("%v", details),
},
})
if err != nil {
}
if asErr, ok := details.(error); ok {
var sqlErr *common.SQLError
if errors.As(asErr, &sqlErr) {
apiErr.SQL = sqlErr.SQL
}
}
w.SetHeader("Content-Type", "application/json")
w.WriteHeader(status)
if err := w.WriteJSON(common.Response{Success: false, Error: apiErr}); err != nil {
logger.Error("Error sending response: %v", err)
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
@@ -579,8 +580,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// preload LEFT JOIN (to prevent "table name specified more than once" errors).
if len(options.CustomSQLJoin) > 0 {
preloadAliasSet := make(map[string]bool, len(options.Preload))
for _, p := range options.Preload {
if alias := common.RelationPathToBunAlias(p.Relation); alias != "" {
for i := range options.Preload {
if alias := common.RelationPathToBunAlias(options.Preload[i].Relation); alias != "" {
preloadAliasSet[alias] = true
}
}
@@ -2645,6 +2646,12 @@ func (h *Handler) sendError(w common.ResponseWriter, statusCode int, code, messa
"_error": errorMsg,
"_retval": 1,
}
var sqlErr *common.SQLError
if errors.As(err, &sqlErr) {
response["_sql"] = sqlErr.SQL
}
w.SetHeader("Content-Type", "application/json")
w.WriteHeader(statusCode)
if jsonErr := w.WriteJSON(response); jsonErr != nil {

View File

@@ -3,6 +3,7 @@ package websocketspec
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
@@ -17,6 +18,17 @@ import (
"github.com/bitechdev/ResolveSpec/pkg/reflection"
)
// newErrorResponseFromErr creates an error response from a Go error, including the SQL
// query in the error info when the error is a database SQLError.
func newErrorResponseFromErr(id, code string, err error) *ResponseMessage {
resp := NewErrorResponse(id, code, err.Error())
var sqlErr *common.SQLError
if errors.As(err, &sqlErr) {
resp.Error.SQL = sqlErr.SQL
}
return resp
}
// Handler handles WebSocket connections and messages
type Handler struct {
db common.Database
@@ -236,7 +248,7 @@ func (h *Handler) handleRead(conn *Connection, msg *Message, hookCtx *HookContex
if err != nil {
logger.Error("[WebSocketSpec] Read operation failed: %v", err)
errResp := NewErrorResponse(msg.ID, "read_error", err.Error())
errResp := newErrorResponseFromErr(msg.ID, "read_error", err)
_ = conn.SendJSON(errResp)
return
}
@@ -272,7 +284,7 @@ func (h *Handler) handleCreate(conn *Connection, msg *Message, hookCtx *HookCont
data, err := h.create(hookCtx)
if err != nil {
logger.Error("[WebSocketSpec] Create operation failed: %v", err)
errResp := NewErrorResponse(msg.ID, "create_error", err.Error())
errResp := newErrorResponseFromErr(msg.ID, "create_error", err)
_ = conn.SendJSON(errResp)
return
}
@@ -310,7 +322,7 @@ func (h *Handler) handleUpdate(conn *Connection, msg *Message, hookCtx *HookCont
data, err := h.update(hookCtx)
if err != nil {
logger.Error("[WebSocketSpec] Update operation failed: %v", err)
errResp := NewErrorResponse(msg.ID, "update_error", err.Error())
errResp := newErrorResponseFromErr(msg.ID, "update_error", err)
_ = conn.SendJSON(errResp)
return
}
@@ -348,7 +360,7 @@ func (h *Handler) handleDelete(conn *Connection, msg *Message, hookCtx *HookCont
err := h.delete(hookCtx)
if err != nil {
logger.Error("[WebSocketSpec] Delete operation failed: %v", err)
errResp := NewErrorResponse(msg.ID, "delete_error", err.Error())
errResp := newErrorResponseFromErr(msg.ID, "delete_error", err)
_ = conn.SendJSON(errResp)
return
}

View File

@@ -99,6 +99,9 @@ type ErrorInfo struct {
// Details contains additional error context
Details map[string]interface{} `json:"details,omitempty"`
// SQL is the query that caused the error, populated for database errors
SQL string `json:"sql,omitempty"`
}
// RequestMessage represents a client request