diff --git a/pkg/common/adapters/database/bun.go b/pkg/common/adapters/database/bun.go index 9c3131c..18c98bc 100644 --- a/pkg/common/adapters/database/bun.go +++ b/pkg/common/adapters/database/bun.go @@ -1529,7 +1529,7 @@ func (b *BunUpdateQuery) SetMap(values map[string]interface{}) common.UpdateQuer // Skip primary key updates continue } - b.query = b.query.Set(column+" = ?", value) + b.query = b.query.Set(column+" = ?", common.ConvertSliceForBun(value)) } return b } diff --git a/pkg/common/handler_utils.go b/pkg/common/handler_utils.go index 6e1ee12..b687dec 100644 --- a/pkg/common/handler_utils.go +++ b/pkg/common/handler_utils.go @@ -3,6 +3,7 @@ package common import ( "fmt" "reflect" + "strconv" "strings" "github.com/bitechdev/ResolveSpec/pkg/logger" @@ -261,3 +262,48 @@ func GetTableNameFromModel(model interface{}) string { // This handles cases like "MasterTaskItem" -> "mastertaskitem" return strings.ToLower(modelType.Name()) } + +// ConvertSliceForBun converts []interface{} values to PostgreSQL array literal strings. +// BUN's fallback appender for []interface{} is JSON encoding, which produces "[]" — +// invalid PostgreSQL array syntax. PostgreSQL expects "{}" for empty arrays and +// "{elem1,elem2}" for non-empty ones. All other value types are returned unchanged. +func ConvertSliceForBun(value interface{}) interface{} { + arr, ok := value.([]interface{}) + if !ok { + return value + } + if len(arr) == 0 { + return "{}" + } + parts := make([]string, len(arr)) + for i, elem := range arr { + switch e := elem.(type) { + case string: + needsQuote := e == "" || strings.ContainsAny(e, `,"\\{}`+"\t\n\r ") + if needsQuote { + e = strings.ReplaceAll(e, `\`, `\\`) + e = strings.ReplaceAll(e, `"`, `""`) + parts[i] = `"` + e + `"` + } else { + parts[i] = e + } + case float64: + if e == float64(int64(e)) { + parts[i] = strconv.FormatInt(int64(e), 10) + } else { + parts[i] = strconv.FormatFloat(e, 'f', -1, 64) + } + case bool: + if e { + parts[i] = "t" + } else { + parts[i] = "f" + } + case nil: + parts[i] = "NULL" + default: + parts[i] = fmt.Sprintf("%v", e) + } + } + return "{" + strings.Join(parts, ",") + "}" +} diff --git a/pkg/common/handler_utils_test.go b/pkg/common/handler_utils_test.go index 05d374f..8b90f10 100644 --- a/pkg/common/handler_utils_test.go +++ b/pkg/common/handler_utils_test.go @@ -106,3 +106,66 @@ func TestExtractTagValue(t *testing.T) { }) } } + +func TestConvertSliceForBun(t *testing.T) { + tests := []struct { + name string + input interface{} + expected interface{} + }{ + { + name: "empty slice produces empty pg array", + input: []interface{}{}, + expected: "{}", + }, + { + name: "string elements", + input: []interface{}{"a", "b", "c"}, + expected: "{a,b,c}", + }, + { + name: "string element needing quotes", + input: []interface{}{"hello world", "ok"}, + expected: `{"hello world",ok}`, + }, + { + name: "string with comma", + input: []interface{}{"a,b"}, + expected: `{"a,b"}`, + }, + { + name: "integer elements (JSON float64)", + input: []interface{}{float64(1), float64(2), float64(3)}, + expected: "{1,2,3}", + }, + { + name: "bool elements", + input: []interface{}{true, false}, + expected: "{t,f}", + }, + { + name: "nil input passthrough", + input: nil, + expected: nil, + }, + { + name: "string input passthrough", + input: "hello", + expected: "hello", + }, + { + name: "int input passthrough", + input: 42, + expected: 42, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConvertSliceForBun(tt.input) + if result != tt.expected { + t.Errorf("ConvertSliceForBun(%v) = %v; want %v", tt.input, result, tt.expected) + } + }) + } +} diff --git a/pkg/common/recursive_crud.go b/pkg/common/recursive_crud.go index 572d353..f52b5e3 100644 --- a/pkg/common/recursive_crud.go +++ b/pkg/common/recursive_crud.go @@ -269,7 +269,7 @@ func (p *NestedCUDProcessor) processInsert( query := p.db.NewInsert().Table(tableName) for key, value := range data { - query = query.Value(key, value) + query = query.Value(key, ConvertSliceForBun(value)) } pkName := reflection.GetPrimaryKeyName(tableName) // Add RETURNING clause to get the inserted ID diff --git a/pkg/resolvespec/handler.go b/pkg/resolvespec/handler.go index 73be572..6654f68 100644 --- a/pkg/resolvespec/handler.go +++ b/pkg/resolvespec/handler.go @@ -603,7 +603,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat // Standard processing without nested relations query := h.db.NewInsert().Table(tableName) for key, value := range v { - query = query.Value(key, value) + query = query.Value(key, common.ConvertSliceForBun(value)) } result, err := query.Exec(ctx) if err != nil { @@ -669,7 +669,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat for _, item := range v { txQuery := tx.NewInsert().Table(tableName) for key, value := range item { - txQuery = txQuery.Value(key, value) + txQuery = txQuery.Value(key, common.ConvertSliceForBun(value)) } if _, err := txQuery.Exec(ctx); err != nil { return err @@ -747,7 +747,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat if itemMap, ok := item.(map[string]interface{}); ok { txQuery := tx.NewInsert().Table(tableName) for key, value := range itemMap { - txQuery = txQuery.Value(key, value) + txQuery = txQuery.Value(key, common.ConvertSliceForBun(value)) } if _, err := txQuery.Exec(ctx); err != nil { return err