Compare commits

...

2 Commits

Author SHA1 Message Date
Hein
745564f2e7 More Panic Recovery for reflection on orm 2025-11-20 15:20:21 +02:00
Hein
311e50bfdd Better relation lookup
Some checks are pending
Tests / Run Tests (1.23.x) (push) Waiting to run
Tests / Run Tests (1.24.x) (push) Waiting to run
Tests / Lint Code (push) Waiting to run
Tests / Build (push) Waiting to run
2025-11-20 14:30:59 +02:00
6 changed files with 367 additions and 35 deletions

View File

@@ -9,6 +9,7 @@ import (
"github.com/uptrace/bun"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
"github.com/bitechdev/ResolveSpec/pkg/reflection"
)
@@ -43,12 +44,22 @@ func (b *BunAdapter) NewDelete() common.DeleteQuery {
return &BunDeleteQuery{query: b.db.NewDelete()}
}
func (b *BunAdapter) Exec(ctx context.Context, query string, args ...interface{}) (common.Result, error) {
func (b *BunAdapter) Exec(ctx context.Context, query string, args ...interface{}) (res common.Result, err error) {
defer func() {
if panicErr := logger.RecoverPanic("BunAdapter.Exec"); panicErr != nil {
err = panicErr
}
}()
result, err := b.db.ExecContext(ctx, query, args...)
return &BunResult{result: result}, err
}
func (b *BunAdapter) Query(ctx context.Context, dest interface{}, query string, args ...interface{}) error {
func (b *BunAdapter) Query(ctx context.Context, dest interface{}, query string, args ...interface{}) (err error) {
defer func() {
if panicErr := logger.RecoverPanic("BunAdapter.Query"); panicErr != nil {
err = panicErr
}
}()
return b.db.NewRaw(query, args...).Scan(ctx, dest)
}
@@ -73,7 +84,12 @@ func (b *BunAdapter) RollbackTx(ctx context.Context) error {
return nil
}
func (b *BunAdapter) RunInTransaction(ctx context.Context, fn func(common.Database) error) error {
func (b *BunAdapter) RunInTransaction(ctx context.Context, fn func(common.Database) error) (err error) {
defer func() {
if panicErr := logger.RecoverPanic("BunAdapter.RunInTransaction"); panicErr != nil {
err = panicErr
}
}()
return b.db.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error {
// Create adapter with transaction
adapter := &BunTxAdapter{tx: tx}
@@ -276,15 +292,38 @@ func (b *BunSelectQuery) Having(having string, args ...interface{}) common.Selec
return b
}
func (b *BunSelectQuery) Scan(ctx context.Context, dest interface{}) error {
func (b *BunSelectQuery) Scan(ctx context.Context, dest interface{}) (err error) {
defer func() {
if panicErr := logger.RecoverPanic("BunSelectQuery.Scan"); panicErr != nil {
err = panicErr
}
}()
if dest == nil {
return fmt.Errorf("destination cannot be nil")
}
return b.query.Scan(ctx, dest)
}
func (b *BunSelectQuery) ScanModel(ctx context.Context) error {
func (b *BunSelectQuery) ScanModel(ctx context.Context) (err error) {
defer func() {
if panicErr := logger.RecoverPanic("BunSelectQuery.ScanModel"); panicErr != nil {
err = panicErr
}
}()
if b.query.GetModel() == nil {
return fmt.Errorf("model is nil")
}
return b.query.Scan(ctx)
}
func (b *BunSelectQuery) Count(ctx context.Context) (int, error) {
func (b *BunSelectQuery) Count(ctx context.Context) (count int, err error) {
defer func() {
if panicErr := logger.RecoverPanic("BunSelectQuery.Count"); panicErr != nil {
err = panicErr
count = 0
}
}()
// If Model() was set, use bun's native Count() which works properly
if b.hasModel {
count, err := b.query.Count(ctx)
@@ -293,15 +332,20 @@ func (b *BunSelectQuery) Count(ctx context.Context) (int, error) {
// Otherwise, wrap as subquery to avoid "Model(nil)" error
// This is needed when only Table() is set without a model
var count int
err := b.db.NewSelect().
err = b.db.NewSelect().
TableExpr("(?) AS subquery", b.query).
ColumnExpr("COUNT(*)").
Scan(ctx, &count)
return count, err
}
func (b *BunSelectQuery) Exists(ctx context.Context) (bool, error) {
func (b *BunSelectQuery) Exists(ctx context.Context) (exists bool, err error) {
defer func() {
if panicErr := logger.RecoverPanic("BunSelectQuery.Exists"); panicErr != nil {
err = panicErr
exists = false
}
}()
return b.query.Exists(ctx)
}
@@ -320,7 +364,6 @@ func (b *BunInsertQuery) Model(model interface{}) common.InsertQuery {
func (b *BunInsertQuery) Table(table string) common.InsertQuery {
if b.hasModel {
// If model is set, do not override table name
return b
}
b.query = b.query.Table(table)
@@ -347,7 +390,12 @@ func (b *BunInsertQuery) Returning(columns ...string) common.InsertQuery {
return b
}
func (b *BunInsertQuery) Exec(ctx context.Context) (common.Result, error) {
func (b *BunInsertQuery) Exec(ctx context.Context) (res common.Result, err error) {
defer func() {
if panicErr := logger.RecoverPanic("BunInsertQuery.Exec"); panicErr != nil {
err = panicErr
}
}()
if b.values != nil && len(b.values) > 0 {
if !b.hasModel {
// If no model was set, use the values map as the model
@@ -428,7 +476,12 @@ func (b *BunUpdateQuery) Returning(columns ...string) common.UpdateQuery {
return b
}
func (b *BunUpdateQuery) Exec(ctx context.Context) (common.Result, error) {
func (b *BunUpdateQuery) Exec(ctx context.Context) (res common.Result, err error) {
defer func() {
if panicErr := logger.RecoverPanic("BunUpdateQuery.Exec"); panicErr != nil {
err = panicErr
}
}()
result, err := b.query.Exec(ctx)
return &BunResult{result: result}, err
}
@@ -453,7 +506,12 @@ func (b *BunDeleteQuery) Where(query string, args ...interface{}) common.DeleteQ
return b
}
func (b *BunDeleteQuery) Exec(ctx context.Context) (common.Result, error) {
func (b *BunDeleteQuery) Exec(ctx context.Context) (res common.Result, err error) {
defer func() {
if panicErr := logger.RecoverPanic("BunDeleteQuery.Exec"); panicErr != nil {
err = panicErr
}
}()
result, err := b.query.Exec(ctx)
return &BunResult{result: result}, err
}

View File

@@ -8,6 +8,7 @@ import (
"gorm.io/gorm"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
"github.com/bitechdev/ResolveSpec/pkg/reflection"
)
@@ -38,12 +39,22 @@ func (g *GormAdapter) NewDelete() common.DeleteQuery {
return &GormDeleteQuery{db: g.db}
}
func (g *GormAdapter) Exec(ctx context.Context, query string, args ...interface{}) (common.Result, error) {
func (g *GormAdapter) Exec(ctx context.Context, query string, args ...interface{}) (res common.Result, err error) {
defer func() {
if panicErr := logger.RecoverPanic("GormAdapter.Exec"); panicErr != nil {
err = panicErr
}
}()
result := g.db.WithContext(ctx).Exec(query, args...)
return &GormResult{result: result}, result.Error
}
func (g *GormAdapter) Query(ctx context.Context, dest interface{}, query string, args ...interface{}) error {
func (g *GormAdapter) Query(ctx context.Context, dest interface{}, query string, args ...interface{}) (err error) {
defer func() {
if panicErr := logger.RecoverPanic("GormAdapter.Query"); panicErr != nil {
err = panicErr
}
}()
return g.db.WithContext(ctx).Raw(query, args...).Find(dest).Error
}
@@ -63,7 +74,12 @@ func (g *GormAdapter) RollbackTx(ctx context.Context) error {
return g.db.WithContext(ctx).Rollback().Error
}
func (g *GormAdapter) RunInTransaction(ctx context.Context, fn func(common.Database) error) error {
func (g *GormAdapter) RunInTransaction(ctx context.Context, fn func(common.Database) error) (err error) {
defer func() {
if panicErr := logger.RecoverPanic("GormAdapter.RunInTransaction"); panicErr != nil {
err = panicErr
}
}()
return g.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
adapter := &GormAdapter{db: tx}
return fn(adapter)
@@ -255,26 +271,48 @@ func (g *GormSelectQuery) Having(having string, args ...interface{}) common.Sele
return g
}
func (g *GormSelectQuery) Scan(ctx context.Context, dest interface{}) error {
func (g *GormSelectQuery) Scan(ctx context.Context, dest interface{}) (err error) {
defer func() {
if panicErr := logger.RecoverPanic("GormSelectQuery.Scan"); panicErr != nil {
err = panicErr
}
}()
return g.db.WithContext(ctx).Find(dest).Error
}
func (g *GormSelectQuery) ScanModel(ctx context.Context) error {
func (g *GormSelectQuery) ScanModel(ctx context.Context) (err error) {
defer func() {
if panicErr := logger.RecoverPanic("GormSelectQuery.ScanModel"); panicErr != nil {
err = panicErr
}
}()
if g.db.Statement.Model == nil {
return fmt.Errorf("ScanModel requires Model() to be set before scanning")
}
return g.db.WithContext(ctx).Find(g.db.Statement.Model).Error
}
func (g *GormSelectQuery) Count(ctx context.Context) (int, error) {
var count int64
err := g.db.WithContext(ctx).Count(&count).Error
return int(count), err
func (g *GormSelectQuery) Count(ctx context.Context) (count int, err error) {
defer func() {
if panicErr := logger.RecoverPanic("GormSelectQuery.Count"); panicErr != nil {
err = panicErr
count = 0
}
}()
var count64 int64
err = g.db.WithContext(ctx).Count(&count64).Error
return int(count64), err
}
func (g *GormSelectQuery) Exists(ctx context.Context) (bool, error) {
func (g *GormSelectQuery) Exists(ctx context.Context) (exists bool, err error) {
defer func() {
if panicErr := logger.RecoverPanic("GormSelectQuery.Exists"); panicErr != nil {
err = panicErr
exists = false
}
}()
var count int64
err := g.db.WithContext(ctx).Limit(1).Count(&count).Error
err = g.db.WithContext(ctx).Limit(1).Count(&count).Error
return count > 0, err
}
@@ -314,7 +352,12 @@ func (g *GormInsertQuery) Returning(columns ...string) common.InsertQuery {
return g
}
func (g *GormInsertQuery) Exec(ctx context.Context) (common.Result, error) {
func (g *GormInsertQuery) Exec(ctx context.Context) (res common.Result, err error) {
defer func() {
if panicErr := logger.RecoverPanic("GormInsertQuery.Exec"); panicErr != nil {
err = panicErr
}
}()
var result *gorm.DB
switch {
case g.model != nil:
@@ -401,7 +444,12 @@ func (g *GormUpdateQuery) Returning(columns ...string) common.UpdateQuery {
return g
}
func (g *GormUpdateQuery) Exec(ctx context.Context) (common.Result, error) {
func (g *GormUpdateQuery) Exec(ctx context.Context) (res common.Result, err error) {
defer func() {
if panicErr := logger.RecoverPanic("GormUpdateQuery.Exec"); panicErr != nil {
err = panicErr
}
}()
result := g.db.WithContext(ctx).Updates(g.updates)
return &GormResult{result: result}, result.Error
}
@@ -428,7 +476,12 @@ func (g *GormDeleteQuery) Where(query string, args ...interface{}) common.Delete
return g
}
func (g *GormDeleteQuery) Exec(ctx context.Context) (common.Result, error) {
func (g *GormDeleteQuery) Exec(ctx context.Context) (res common.Result, err error) {
defer func() {
if panicErr := logger.RecoverPanic("GormDeleteQuery.Exec"); panicErr != nil {
err = panicErr
}
}()
result := g.db.WithContext(ctx).Delete(g.model)
return &GormResult{result: result}, result.Error
}

View File

@@ -103,3 +103,14 @@ func CatchPanicCallback(location string, cb func(err any)) {
func CatchPanic(location string) {
CatchPanicCallback(location, nil)
}
// RecoverPanic recovers from panics and returns an error
// Use this in deferred functions to convert panics into errors
func RecoverPanic(methodName string) error {
if r := recover(); r != nil {
stack := debug.Stack()
Error("Panic in %s: %v\nStack trace:\n%s", methodName, r, string(stack))
return fmt.Errorf("panic in %s: %v", methodName, r)
}
return nil
}

View File

@@ -13,6 +13,7 @@ const (
contextKeyTableName contextKey = "tableName"
contextKeyModel contextKey = "model"
contextKeyModelPtr contextKey = "modelPtr"
contextKeyOptions contextKey = "options"
)
// WithSchema adds schema to context
@@ -74,12 +75,28 @@ func GetModelPtr(ctx context.Context) interface{} {
return ctx.Value(contextKeyModelPtr)
}
// WithOptions adds request options to context
func WithOptions(ctx context.Context, options ExtendedRequestOptions) context.Context {
return context.WithValue(ctx, contextKeyOptions, options)
}
// GetOptions retrieves request options from context
func GetOptions(ctx context.Context) *ExtendedRequestOptions {
if v := ctx.Value(contextKeyOptions); v != nil {
if opts, ok := v.(ExtendedRequestOptions); ok {
return &opts
}
}
return nil
}
// WithRequestData adds all request-scoped data to context at once
func WithRequestData(ctx context.Context, schema, entity, tableName string, model, modelPtr interface{}) context.Context {
func WithRequestData(ctx context.Context, schema, entity, tableName string, model, modelPtr interface{}, options ExtendedRequestOptions) context.Context {
ctx = WithSchema(ctx, schema)
ctx = WithEntity(ctx, entity)
ctx = WithTableName(ctx, tableName)
ctx = WithModel(ctx, model)
ctx = WithModelPtr(ctx, modelPtr)
ctx = WithOptions(ctx, options)
return ctx
}

View File

@@ -65,9 +65,6 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s
entity := params["entity"]
id := params["id"]
// Parse options from headers (now returns ExtendedRequestOptions)
options := h.parseOptionsFromHeaders(r)
// Determine operation based on HTTP method
method := r.Method()
@@ -104,13 +101,16 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s
modelPtr := reflect.New(reflect.TypeOf(model)).Interface()
tableName := h.getTableName(schema, entity, model)
// Add request-scoped data to context
ctx = WithRequestData(ctx, schema, entity, tableName, model, modelPtr)
// Parse options from headers - this now includes relation name resolution
options := h.parseOptionsFromHeaders(r, model)
// Validate and filter columns in options (log warnings for invalid columns)
validator := common.NewColumnValidator(model)
options = filterExtendedOptions(validator, options)
// Add request-scoped data to context (including options)
ctx = WithRequestData(ctx, schema, entity, tableName, model, modelPtr, options)
switch method {
case "GET":
if id != "" {

View File

@@ -99,7 +99,8 @@ func DecodeParam(pStr string) (string, error) {
}
// parseOptionsFromHeaders parses all request options from HTTP headers
func (h *Handler) parseOptionsFromHeaders(r common.Request) ExtendedRequestOptions {
// If model is provided, it will resolve table names to field names in preload/expand options
func (h *Handler) parseOptionsFromHeaders(r common.Request, model interface{}) ExtendedRequestOptions {
options := ExtendedRequestOptions{
RequestOptions: common.RequestOptions{
Filters: make([]common.FilterOption, 0),
@@ -225,6 +226,11 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request) ExtendedRequestOptio
}
}
// Resolve relation names (convert table names to field names) if model is provided
if model != nil {
h.resolveRelationNamesInOptions(&options, model)
}
return options
}
@@ -655,6 +661,192 @@ func (h *Handler) processXFilesRelations(xfiles *XFiles, options *ExtendedReques
}
}
// resolveRelationNamesInOptions resolves all table names to field names in preload options
// This is called internally by parseOptionsFromHeaders when a model is provided
func (h *Handler) resolveRelationNamesInOptions(options *ExtendedRequestOptions, model interface{}) {
if options == nil || model == nil {
return
}
// Resolve relation names in all preload options
for i := range options.Preload {
preload := &options.Preload[i]
// Split the relation path (e.g., "parent.child.grandchild")
parts := strings.Split(preload.Relation, ".")
resolvedParts := make([]string, 0, len(parts))
// Resolve each part of the path
currentModel := model
for _, part := range parts {
resolvedPart := h.resolveRelationName(currentModel, part)
resolvedParts = append(resolvedParts, resolvedPart)
// Try to get the model type for the next level
// This allows nested resolution
if nextModel := h.getRelationModel(currentModel, resolvedPart); nextModel != nil {
currentModel = nextModel
}
}
// Update the relation path with resolved names
resolvedPath := strings.Join(resolvedParts, ".")
if resolvedPath != preload.Relation {
logger.Debug("Resolved relation path '%s' -> '%s'", preload.Relation, resolvedPath)
preload.Relation = resolvedPath
}
}
// Resolve relation names in expand options
for i := range options.Expand {
expand := &options.Expand[i]
resolved := h.resolveRelationName(model, expand.Relation)
if resolved != expand.Relation {
logger.Debug("Resolved expand relation '%s' -> '%s'", expand.Relation, resolved)
expand.Relation = resolved
}
}
}
// getRelationModel gets the model type for a relation field
func (h *Handler) getRelationModel(model interface{}, fieldName string) interface{} {
if model == nil || fieldName == "" {
return nil
}
modelType := reflect.TypeOf(model)
if modelType == nil {
return nil
}
if modelType.Kind() == reflect.Ptr {
modelType = modelType.Elem()
}
if modelType == nil || modelType.Kind() != reflect.Struct {
return nil
}
// Find the field
field, found := modelType.FieldByName(fieldName)
if !found {
return nil
}
// Get the target type
targetType := field.Type
if targetType == nil {
return nil
}
if targetType.Kind() == reflect.Slice {
targetType = targetType.Elem()
if targetType == nil {
return nil
}
}
if targetType.Kind() == reflect.Ptr {
targetType = targetType.Elem()
if targetType == nil {
return nil
}
}
if targetType.Kind() != reflect.Struct {
return nil
}
// Create a zero value of the target type
return reflect.New(targetType).Elem().Interface()
}
// resolveRelationName resolves a relation name or table name to the actual field name in the model
// If the input is already a field name, it returns it as-is
// If the input is a table name, it looks up the corresponding relation field
func (h *Handler) resolveRelationName(model interface{}, nameOrTable string) string {
if model == nil || nameOrTable == "" {
return nameOrTable
}
modelType := reflect.TypeOf(model)
if modelType == nil {
return nameOrTable
}
// Dereference pointer if needed
if modelType.Kind() == reflect.Ptr {
modelType = modelType.Elem()
}
// Check again after dereferencing
if modelType == nil {
return nameOrTable
}
// Ensure it's a struct
if modelType.Kind() != reflect.Struct {
return nameOrTable
}
// First, check if the input matches a field name directly
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
if field.Name == nameOrTable {
// It's already a field name
logger.Debug("Input '%s' is a field name", nameOrTable)
return nameOrTable
}
}
// If not found as a field name, try to look it up as a table name
normalizedInput := strings.ToLower(strings.ReplaceAll(nameOrTable, "_", ""))
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
fieldType := field.Type
// Check if it's a slice or pointer to a struct
var targetType reflect.Type
if fieldType.Kind() == reflect.Slice {
targetType = fieldType.Elem()
} else if fieldType.Kind() == reflect.Ptr {
targetType = fieldType.Elem()
}
if targetType != nil {
// Dereference pointer if the slice contains pointers
if targetType.Kind() == reflect.Ptr {
targetType = targetType.Elem()
}
// Check if it's a struct type
if targetType.Kind() == reflect.Struct {
// Get the type name and normalize it
typeName := targetType.Name()
// Extract the table name from type name
// Patterns: ModelCoreMastertaskitem -> mastertaskitem
// ModelMastertaskitem -> mastertaskitem
normalizedTypeName := strings.ToLower(typeName)
// Remove common prefixes like "model", "modelcore", etc.
normalizedTypeName = strings.TrimPrefix(normalizedTypeName, "modelcore")
normalizedTypeName = strings.TrimPrefix(normalizedTypeName, "model")
// Compare normalized names
if normalizedTypeName == normalizedInput {
logger.Debug("Resolved table name '%s' to field '%s' (type: %s)", nameOrTable, field.Name, typeName)
return field.Name
}
}
}
}
// If no match found, return the original input
logger.Debug("No field found for '%s', using as-is", nameOrTable)
return nameOrTable
}
// addXFilesPreload converts an XFiles relation into a PreloadOption
// and recursively processes its children
func (h *Handler) addXFilesPreload(xfile *XFiles, options *ExtendedRequestOptions, basePath string) {
@@ -662,7 +854,8 @@ func (h *Handler) addXFilesPreload(xfile *XFiles, options *ExtendedRequestOption
return
}
// Determine the relation path
// Store the table name as-is for now - it will be resolved to field name later
// when we have the model instance available
relationPath := xfile.TableName
if basePath != "" {
relationPath = basePath + "." + xfile.TableName