From c42d09238f49026cade654ccb1755af435f61cab Mon Sep 17 00:00:00 2001 From: Hein Date: Wed, 20 May 2026 13:06:26 +0200 Subject: [PATCH] fix: better error detail for failed sql --- pkg/common/adapters/database/bun.go | 8 +++++- pkg/common/adapters/database/gorm.go | 6 +++++ pkg/common/adapters/database/pgsql.go | 37 ++++++++++++++++----------- pkg/common/types.go | 19 ++++++++++++++ pkg/funcspec/function_api.go | 5 ++++ pkg/resolvespec/handler.go | 24 +++++++++-------- pkg/restheadspec/handler.go | 11 ++++++-- pkg/websocketspec/handler.go | 20 ++++++++++++--- pkg/websocketspec/message.go | 3 +++ 9 files changed, 101 insertions(+), 32 deletions(-) diff --git a/pkg/common/adapters/database/bun.go b/pkg/common/adapters/database/bun.go index 938ae1b..ba3dc62 100644 --- a/pkg/common/adapters/database/bun.go +++ b/pkg/common/adapters/database/bun.go @@ -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 diff --git a/pkg/common/adapters/database/gorm.go b/pkg/common/adapters/database/gorm.go index 8afa4af..49f9dec 100644 --- a/pkg/common/adapters/database/gorm.go +++ b/pkg/common/adapters/database/gorm.go @@ -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 diff --git a/pkg/common/adapters/database/pgsql.go b/pkg/common/adapters/database/pgsql.go index ae293ab..5ac862a 100644 --- a/pkg/common/adapters/database/pgsql.go +++ b/pkg/common/adapters/database/pgsql.go @@ -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() diff --git a/pkg/common/types.go b/pkg/common/types.go index 15e0f53..42537a6 100644 --- a/pkg/common/types.go +++ b/pkg/common/types.go @@ -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 { diff --git a/pkg/funcspec/function_api.go b/pkg/funcspec/function_api.go index ff5eb2d..692dfd2 100644 --- a/pkg/funcspec/function_api.go +++ b/pkg/funcspec/function_api.go @@ -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{}{ diff --git a/pkg/resolvespec/handler.go b/pkg/resolvespec/handler.go index 6654f68..b6a0b97 100644 --- a/pkg/resolvespec/handler.go +++ b/pkg/resolvespec/handler.go @@ -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{}) { + apiErr := &common.APIError{ + Code: code, + Message: message, + Details: details, + Detail: fmt.Sprintf("%v", details), + } + 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) - err := w.WriteJSON(common.Response{ - Success: false, - Error: &common.APIError{ - Code: code, - Message: message, - Details: details, - Detail: fmt.Sprintf("%v", details), - }, - }) - if err != nil { + if err := w.WriteJSON(common.Response{Success: false, Error: apiErr}); err != nil { logger.Error("Error sending response: %v", err) } } diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index 3d818b3..0cd941d 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -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 { diff --git a/pkg/websocketspec/handler.go b/pkg/websocketspec/handler.go index e6c12e0..e3881d8 100644 --- a/pkg/websocketspec/handler.go +++ b/pkg/websocketspec/handler.go @@ -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 } diff --git a/pkg/websocketspec/message.go b/pkg/websocketspec/message.go index 6280c7b..bb1d757 100644 --- a/pkg/websocketspec/message.go +++ b/pkg/websocketspec/message.go @@ -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