Compare commits

..

8 Commits

Author SHA1 Message Date
Hein
0ac207d80f fix: better update handling 2026-01-13 11:33:45 +02:00
Hein
b7a67a6974 fix(headers): 🐛 handle search on computed columns
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -27m24s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -26m56s
Build , Vet Test, and Lint / Build (push) Successful in -27m1s
Build , Vet Test, and Lint / Lint Code (push) Successful in -26m32s
Tests / Integration Tests (push) Failing after -27m44s
Tests / Unit Tests (push) Successful in -27m26s
2026-01-12 11:12:42 +02:00
Hein
cb20a354fc feat(cors): update SetCORSHeaders to accept Request
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -27m41s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 53s
Tests / Unit Tests (push) Successful in 29s
Build , Vet Test, and Lint / Lint Code (push) Successful in -27m8s
Build , Vet Test, and Lint / Build (push) Successful in -27m25s
Tests / Integration Tests (push) Failing after 37s
* Modify SetCORSHeaders function to include Request parameter.
* Set Access-Control-Allow-Origin and Access-Control-Allow-Headers to "*".
* Update all relevant calls to SetCORSHeaders across the codebase.
2026-01-07 15:24:44 +02:00
Hein
37c85361ba feat(cors): add check for server port in CORS config
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -27m37s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 55s
Build , Vet Test, and Lint / Lint Code (push) Successful in -27m0s
Build , Vet Test, and Lint / Build (push) Successful in -27m20s
Tests / Integration Tests (push) Failing after -27m54s
Tests / Unit Tests (push) Successful in 2m1s
2026-01-07 12:06:08 +02:00
Hein
a7e640a6a1 fix(recursive_crud): 🐛 use dynamic primary key name in insert
* Update processInsert to use the primary key name dynamically.
* Ensure correct ID retrieval from data based on primary key.
2026-01-07 11:58:44 +02:00
Hein
bf7125efc3 feat(reflection): add ExtractTagValue and GetRelationshipInfo functions
* Implement ExtractTagValue to handle struct tag parsing.
* Introduce GetRelationshipInfo for extracting relationship metadata.
* Update tests to validate new functionality.
* Refactor related code for improved clarity and maintainability.
2026-01-07 11:54:12 +02:00
Hein
e220ab3d34 refactor(reflection): 🛠️ comment out ToSnakeCase usage in MapToStruct 2026-01-07 10:23:37 +02:00
Hein
6a0297713a feat(reflection): enhance ToSnakeCase and add convertSlice function
* Improve ToSnakeCase to handle consecutive uppercase letters.
* Introduce convertSlice for element-wise conversions between slices.
* Update setFieldValue to support new slice conversion logic.
2026-01-07 10:23:23 +02:00
14 changed files with 1236 additions and 465 deletions

View File

@@ -26,10 +26,13 @@ func DefaultCORSConfig() CORSConfig {
for i := range cfg.Servers.Instances {
server := cfg.Servers.Instances[i]
if server.Port == 0 {
continue
}
hosts = append(hosts, server.ExternalURLs...)
hosts = append(hosts, fmt.Sprintf("http://%s:%d", server.Host, server.Port))
hosts = append(hosts, fmt.Sprintf("https://%s:%d", server.Host, server.Port))
hosts = append(hosts, fmt.Sprintf("http://%s:%d", "localhost", server.Port))
hosts = append(hosts, server.ExternalURLs...)
for _, ip := range ipsList {
hosts = append(hosts, fmt.Sprintf("http://%s:%d", ip.String(), server.Port))
hosts = append(hosts, fmt.Sprintf("https://%s:%d", ip.String(), server.Port))
@@ -111,11 +114,14 @@ func GetHeadSpecHeaders() []string {
}
// SetCORSHeaders sets CORS headers on a response writer
func SetCORSHeaders(w ResponseWriter, config CORSConfig) {
func SetCORSHeaders(w ResponseWriter, r Request, config CORSConfig) {
// Set allowed origins
if len(config.AllowedOrigins) > 0 {
w.SetHeader("Access-Control-Allow-Origin", strings.Join(config.AllowedOrigins, ", "))
}
// if len(config.AllowedOrigins) > 0 {
// w.SetHeader("Access-Control-Allow-Origin", strings.Join(config.AllowedOrigins, ", "))
// }
// Todo origin list parsing
w.SetHeader("Access-Control-Allow-Origin", "*")
// Set allowed methods
if len(config.AllowedMethods) > 0 {
@@ -123,9 +129,10 @@ func SetCORSHeaders(w ResponseWriter, config CORSConfig) {
}
// Set allowed headers
if len(config.AllowedHeaders) > 0 {
w.SetHeader("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
}
// if len(config.AllowedHeaders) > 0 {
// w.SetHeader("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
// }
w.SetHeader("Access-Control-Allow-Headers", "*")
// Set max age
if config.MaxAge > 0 {
@@ -136,5 +143,7 @@ func SetCORSHeaders(w ResponseWriter, config CORSConfig) {
w.SetHeader("Access-Control-Allow-Credentials", "true")
// Expose headers that clients can read
w.SetHeader("Access-Control-Expose-Headers", "Content-Range, X-Api-Range-Total, X-Api-Range-Size")
exposeHeaders := config.AllowedHeaders
exposeHeaders = append(exposeHeaders, "Content-Range", "X-Api-Range-Total", "X-Api-Range-Size")
w.SetHeader("Access-Control-Expose-Headers", strings.Join(exposeHeaders, ", "))
}

View File

@@ -3,6 +3,9 @@ package common
import (
"fmt"
"reflect"
"strings"
"github.com/bitechdev/ResolveSpec/pkg/logger"
)
// ValidateAndUnwrapModelResult contains the result of model validation
@@ -45,3 +48,216 @@ func ValidateAndUnwrapModel(model interface{}) (*ValidateAndUnwrapModelResult, e
OriginalType: originalType,
}, nil
}
// ExtractTagValue extracts the value for a given key from a struct tag string.
// It handles both semicolon and comma-separated tag formats (e.g., GORM and BUN tags).
// For tags like "json:name;validate:required" it will extract "name" for key "json".
// For tags like "rel:has-many,join:table" it will extract "table" for key "join".
func ExtractTagValue(tag, key string) string {
// Split by both semicolons and commas to handle different tag formats
// We need to be smart about this - commas can be part of values
// So we'll try semicolon first, then comma if needed
separators := []string{";", ","}
for _, sep := range separators {
parts := strings.Split(tag, sep)
for _, part := range parts {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, key+":") {
return strings.TrimPrefix(part, key+":")
}
}
}
return ""
}
// GetRelationshipInfo analyzes a model type and extracts relationship metadata
// for a specific relation field identified by its JSON name.
// Returns nil if the field is not found or is not a valid relationship.
func GetRelationshipInfo(modelType reflect.Type, relationName string) *RelationshipInfo {
// Ensure we have a struct type
if modelType == nil || modelType.Kind() != reflect.Struct {
logger.Warn("Cannot get relationship info from non-struct type: %v", modelType)
return nil
}
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
jsonTag := field.Tag.Get("json")
jsonName := strings.Split(jsonTag, ",")[0]
if jsonName == relationName {
gormTag := field.Tag.Get("gorm")
bunTag := field.Tag.Get("bun")
info := &RelationshipInfo{
FieldName: field.Name,
JSONName: jsonName,
}
if strings.Contains(bunTag, "rel:") || strings.Contains(bunTag, "join:") {
//bun:"rel:has-many,join:rid_hub=rid_hub_division"
if strings.Contains(bunTag, "has-many") {
info.RelationType = "hasMany"
} else if strings.Contains(bunTag, "has-one") {
info.RelationType = "hasOne"
} else if strings.Contains(bunTag, "belongs-to") {
info.RelationType = "belongsTo"
} else if strings.Contains(bunTag, "many-to-many") {
info.RelationType = "many2many"
} else {
info.RelationType = "hasOne"
}
// Extract join info
joinPart := ExtractTagValue(bunTag, "join")
if joinPart != "" && info.RelationType == "many2many" {
// For many2many, the join part is the join table name
info.JoinTable = joinPart
} else if joinPart != "" {
// For other relations, parse foreignKey and references
joinParts := strings.Split(joinPart, "=")
if len(joinParts) == 2 {
info.ForeignKey = joinParts[0]
info.References = joinParts[1]
}
}
// Get related model type
if field.Type.Kind() == reflect.Slice {
elemType := field.Type.Elem()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
} else if field.Type.Kind() == reflect.Ptr || field.Type.Kind() == reflect.Struct {
elemType := field.Type
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
}
return info
}
// Parse GORM tag to determine relationship type and keys
if strings.Contains(gormTag, "foreignKey") {
info.ForeignKey = ExtractTagValue(gormTag, "foreignKey")
info.References = ExtractTagValue(gormTag, "references")
// Determine if it's belongsTo or hasMany/hasOne
if field.Type.Kind() == reflect.Slice {
info.RelationType = "hasMany"
// Get the element type for slice
elemType := field.Type.Elem()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
} else if field.Type.Kind() == reflect.Ptr || field.Type.Kind() == reflect.Struct {
info.RelationType = "belongsTo"
elemType := field.Type
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
}
} else if strings.Contains(gormTag, "many2many") {
info.RelationType = "many2many"
info.JoinTable = ExtractTagValue(gormTag, "many2many")
// Get the element type for many2many (always slice)
if field.Type.Kind() == reflect.Slice {
elemType := field.Type.Elem()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
}
} else {
// Field has no GORM relationship tags, so it's not a relation
return nil
}
return info
}
}
return nil
}
// RelationPathToBunAlias converts a relation path (e.g., "Order.Customer") to a Bun alias format.
// It converts to lowercase and replaces dots with double underscores.
// For example: "Order.Customer" -> "order__customer"
func RelationPathToBunAlias(relationPath string) string {
if relationPath == "" {
return ""
}
// Convert to lowercase and replace dots with double underscores
alias := strings.ToLower(relationPath)
alias = strings.ReplaceAll(alias, ".", "__")
return alias
}
// ReplaceTableReferencesInSQL replaces references to a base table name in a SQL expression
// with the appropriate alias for the current preload level.
// For example, if baseTableName is "mastertaskitem" and targetAlias is "mal__mal",
// it will replace "mastertaskitem.rid_mastertaskitem" with "mal__mal.rid_mastertaskitem"
func ReplaceTableReferencesInSQL(sqlExpr, baseTableName, targetAlias string) string {
if sqlExpr == "" || baseTableName == "" || targetAlias == "" {
return sqlExpr
}
// Replace both quoted and unquoted table references
// Handle patterns like: tablename.column, "tablename".column, tablename."column", "tablename"."column"
// Pattern 1: tablename.column (unquoted)
result := strings.ReplaceAll(sqlExpr, baseTableName+".", targetAlias+".")
// Pattern 2: "tablename".column or "tablename"."column" (quoted table name)
result = strings.ReplaceAll(result, "\""+baseTableName+"\".", "\""+targetAlias+"\".")
return result
}
// GetTableNameFromModel extracts the table name from a model.
// It checks the bun tag first, then falls back to converting the struct name to snake_case.
func GetTableNameFromModel(model interface{}) string {
if model == nil {
return ""
}
modelType := reflect.TypeOf(model)
// Unwrap pointers
for modelType != nil && modelType.Kind() == reflect.Ptr {
modelType = modelType.Elem()
}
if modelType == nil || modelType.Kind() != reflect.Struct {
return ""
}
// Look for bun tag on embedded BaseModel
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
if field.Anonymous {
bunTag := field.Tag.Get("bun")
if strings.HasPrefix(bunTag, "table:") {
return strings.TrimPrefix(bunTag, "table:")
}
}
}
// Fallback: convert struct name to lowercase (simple heuristic)
// This handles cases like "MasterTaskItem" -> "mastertaskitem"
return strings.ToLower(modelType.Name())
}

View File

@@ -0,0 +1,108 @@
package common
import (
"testing"
)
func TestExtractTagValue(t *testing.T) {
tests := []struct {
name string
tag string
key string
expected string
}{
{
name: "Extract existing key",
tag: "json:name;validate:required",
key: "json",
expected: "name",
},
{
name: "Extract key with spaces",
tag: "json:name ; validate:required",
key: "validate",
expected: "required",
},
{
name: "Extract key at end",
tag: "json:name;validate:required;db:column_name",
key: "db",
expected: "column_name",
},
{
name: "Extract key at beginning",
tag: "primary:true;json:id;db:user_id",
key: "primary",
expected: "true",
},
{
name: "Key not found",
tag: "json:name;validate:required",
key: "db",
expected: "",
},
{
name: "Empty tag",
tag: "",
key: "json",
expected: "",
},
{
name: "Single key-value pair",
tag: "json:name",
key: "json",
expected: "name",
},
{
name: "Key with empty value",
tag: "json:;validate:required",
key: "json",
expected: "",
},
{
name: "Key with complex value",
tag: "json:user_name,omitempty;validate:required,min=3",
key: "json",
expected: "user_name,omitempty",
},
{
name: "Multiple semicolons",
tag: "json:name;;validate:required",
key: "validate",
expected: "required",
},
{
name: "BUN Tag with comma separator",
tag: "rel:has-many,join:rid_hub=rid_hub_child",
key: "join",
expected: "rid_hub=rid_hub_child",
},
{
name: "Extract foreignKey",
tag: "foreignKey:UserID;references:ID",
key: "foreignKey",
expected: "UserID",
},
{
name: "Extract references",
tag: "foreignKey:UserID;references:ID",
key: "references",
expected: "ID",
},
{
name: "Extract many2many",
tag: "many2many:user_roles",
key: "many2many",
expected: "user_roles",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExtractTagValue(tt.tag, tt.key)
if result != tt.expected {
t.Errorf("ExtractTagValue(%q, %q) = %q; want %q", tt.tag, tt.key, result, tt.expected)
}
})
}
}

View File

@@ -20,17 +20,6 @@ type RelationshipInfoProvider interface {
GetRelationshipInfo(modelType reflect.Type, relationName string) *RelationshipInfo
}
// RelationshipInfo contains information about a model relationship
type RelationshipInfo struct {
FieldName string
JSONName string
RelationType string // "belongsTo", "hasMany", "hasOne", "many2many"
ForeignKey string
References string
JoinTable string
RelatedModel interface{}
}
// NestedCUDProcessor handles recursive processing of nested object graphs
type NestedCUDProcessor struct {
db Database
@@ -218,9 +207,9 @@ func (p *NestedCUDProcessor) processInsert(
for key, value := range data {
query = query.Value(key, value)
}
pkName := reflection.GetPrimaryKeyName(tableName)
// Add RETURNING clause to get the inserted ID
query = query.Returning("id")
query = query.Returning(pkName)
result, err := query.Exec(ctx)
if err != nil {
@@ -231,8 +220,8 @@ func (p *NestedCUDProcessor) processInsert(
var id interface{}
if lastID, err := result.LastInsertId(); err == nil && lastID > 0 {
id = lastID
} else if data["id"] != nil {
id = data["id"]
} else if data[pkName] != nil {
id = data[pkName]
}
logger.Debug("Insert successful, ID: %v, rows affected: %d", id, result.RowsAffected())

View File

@@ -111,3 +111,14 @@ type TableMetadata struct {
Columns []Column `json:"columns"`
Relations []string `json:"relations"`
}
// RelationshipInfo contains information about a model relationship
type RelationshipInfo struct {
FieldName string `json:"field_name"`
JSONName string `json:"json_name"`
RelationType string `json:"relation_type"` // "belongsTo", "hasMany", "hasOne", "many2many"
ForeignKey string `json:"foreign_key"`
References string `json:"references"`
JoinTable string `json:"join_table"`
RelatedModel interface{} `json:"related_model"`
}

View File

@@ -584,11 +584,23 @@ func ExtractSourceColumn(colName string) string {
}
// ToSnakeCase converts a string from CamelCase to snake_case
// Handles consecutive uppercase letters (acronyms) correctly:
// "HTTPServer" -> "http_server", "UserID" -> "user_id", "MyHTTPServer" -> "my_http_server"
func ToSnakeCase(s string) string {
var result strings.Builder
for i, r := range s {
runes := []rune(s)
for i, r := range runes {
if i > 0 && r >= 'A' && r <= 'Z' {
result.WriteRune('_')
// Add underscore if:
// 1. Previous character is lowercase, OR
// 2. Next character is lowercase (transition from acronym to word)
prevIsLower := runes[i-1] >= 'a' && runes[i-1] <= 'z'
nextIsLower := i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z'
if prevIsLower || nextIsLower {
result.WriteRune('_')
}
}
result.WriteRune(r)
}
@@ -936,32 +948,38 @@ func MapToStruct(dataMap map[string]interface{}, target interface{}) error {
// Build list of possible column names for this field
var columnNames []string
// 1. Bun tag
if bunTag := field.Tag.Get("bun"); bunTag != "" && bunTag != "-" {
if colName := ExtractColumnFromBunTag(bunTag); colName != "" {
columnNames = append(columnNames, colName)
}
}
// 2. Gorm tag
if gormTag := field.Tag.Get("gorm"); gormTag != "" && gormTag != "-" {
if colName := ExtractColumnFromGormTag(gormTag); colName != "" {
columnNames = append(columnNames, colName)
}
}
// 3. JSON tag
// 1. JSON tag (primary - most common)
jsonFound := false
if jsonTag := field.Tag.Get("json"); jsonTag != "" && jsonTag != "-" {
parts := strings.Split(jsonTag, ",")
if len(parts) > 0 && parts[0] != "" {
columnNames = append(columnNames, parts[0])
jsonFound = true
}
}
// 4. Field name variations
// 2. Bun tag (fallback if no JSON tag)
if !jsonFound {
if bunTag := field.Tag.Get("bun"); bunTag != "" && bunTag != "-" {
if colName := ExtractColumnFromBunTag(bunTag); colName != "" {
columnNames = append(columnNames, colName)
}
}
}
// 3. Gorm tag (fallback if no JSON tag)
if !jsonFound {
if gormTag := field.Tag.Get("gorm"); gormTag != "" && gormTag != "-" {
if colName := ExtractColumnFromGormTag(gormTag); colName != "" {
columnNames = append(columnNames, colName)
}
}
}
// 4. Field name variations (last resort)
columnNames = append(columnNames, field.Name)
columnNames = append(columnNames, strings.ToLower(field.Name))
columnNames = append(columnNames, ToSnakeCase(field.Name))
// columnNames = append(columnNames, ToSnakeCase(field.Name))
// Map all column name variations to this field index
for _, colName := range columnNames {
@@ -1067,7 +1085,7 @@ func setFieldValue(field reflect.Value, value interface{}) error {
case string:
field.SetBytes([]byte(v))
return nil
case map[string]interface{}, []interface{}:
case map[string]interface{}, []interface{}, []*any, map[string]*any:
// Marshal complex types to JSON for SqlJSONB fields
jsonBytes, err := json.Marshal(v)
if err != nil {
@@ -1077,6 +1095,11 @@ func setFieldValue(field reflect.Value, value interface{}) error {
return nil
}
}
// Handle slice-to-slice conversions (e.g., []interface{} to []*SomeModel)
if valueReflect.Kind() == reflect.Slice {
return convertSlice(field, valueReflect)
}
}
// Handle struct types (like SqlTimeStamp, SqlDate, SqlTime which wrap SqlNull[time.Time])
@@ -1156,6 +1179,92 @@ func setFieldValue(field reflect.Value, value interface{}) error {
return fmt.Errorf("cannot convert %v to %v", valueReflect.Type(), field.Type())
}
// convertSlice converts a source slice to a target slice type, handling element-wise conversions
// Supports converting []interface{} to slices of structs or pointers to structs
func convertSlice(targetSlice reflect.Value, sourceSlice reflect.Value) error {
if sourceSlice.Kind() != reflect.Slice || targetSlice.Kind() != reflect.Slice {
return fmt.Errorf("both source and target must be slices")
}
// Get the element type of the target slice
targetElemType := targetSlice.Type().Elem()
sourceLen := sourceSlice.Len()
// Create a new slice with the same length as the source
newSlice := reflect.MakeSlice(targetSlice.Type(), sourceLen, sourceLen)
// Convert each element
for i := 0; i < sourceLen; i++ {
sourceElem := sourceSlice.Index(i)
targetElem := newSlice.Index(i)
// Get the actual value from the source element
var sourceValue interface{}
if sourceElem.CanInterface() {
sourceValue = sourceElem.Interface()
} else {
continue
}
// Handle nil elements
if sourceValue == nil {
// For pointer types, nil is valid
if targetElemType.Kind() == reflect.Ptr {
targetElem.Set(reflect.Zero(targetElemType))
}
continue
}
// If target element type is a pointer to struct, we need to create new instances
if targetElemType.Kind() == reflect.Ptr {
// Create a new instance of the pointed-to type
newElemPtr := reflect.New(targetElemType.Elem())
// Convert the source value to the struct
switch sv := sourceValue.(type) {
case map[string]interface{}:
// Source is a map, use MapToStruct to populate the new instance
if err := MapToStruct(sv, newElemPtr.Interface()); err != nil {
return fmt.Errorf("failed to convert element %d: %w", i, err)
}
default:
// Try direct conversion or setFieldValue
if err := setFieldValue(newElemPtr.Elem(), sourceValue); err != nil {
return fmt.Errorf("failed to convert element %d: %w", i, err)
}
}
targetElem.Set(newElemPtr)
} else if targetElemType.Kind() == reflect.Struct {
// Target element is a struct (not a pointer)
switch sv := sourceValue.(type) {
case map[string]interface{}:
// Use MapToStruct to populate the element
elemPtr := targetElem.Addr()
if elemPtr.CanInterface() {
if err := MapToStruct(sv, elemPtr.Interface()); err != nil {
return fmt.Errorf("failed to convert element %d: %w", i, err)
}
}
default:
// Try direct conversion
if err := setFieldValue(targetElem, sourceValue); err != nil {
return fmt.Errorf("failed to convert element %d: %w", i, err)
}
}
} else {
// For other types, use setFieldValue
if err := setFieldValue(targetElem, sourceValue); err != nil {
return fmt.Errorf("failed to convert element %d: %w", i, err)
}
}
}
// Set the converted slice to the target field
targetSlice.Set(newSlice)
return nil
}
// convertToInt64 attempts to convert various types to int64
func convertToInt64(value interface{}) (int64, bool) {
switch v := value.(type) {

View File

@@ -0,0 +1,364 @@
package reflection
import (
"testing"
"time"
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/google/uuid"
)
// TestModel contains all spectypes custom types
type TestModel struct {
ID int64 `bun:"id,pk" json:"id"`
Name spectypes.SqlString `bun:"name" json:"name"`
Age spectypes.SqlInt64 `bun:"age" json:"age"`
Score spectypes.SqlFloat64 `bun:"score" json:"score"`
Active spectypes.SqlBool `bun:"active" json:"active"`
UUID spectypes.SqlUUID `bun:"uuid" json:"uuid"`
CreatedAt spectypes.SqlTimeStamp `bun:"created_at" json:"created_at"`
BirthDate spectypes.SqlDate `bun:"birth_date" json:"birth_date"`
StartTime spectypes.SqlTime `bun:"start_time" json:"start_time"`
Metadata spectypes.SqlJSONB `bun:"metadata" json:"metadata"`
Count16 spectypes.SqlInt16 `bun:"count16" json:"count16"`
Count32 spectypes.SqlInt32 `bun:"count32" json:"count32"`
}
// TestMapToStruct_AllSpectypes verifies that MapToStruct can convert all spectypes correctly
func TestMapToStruct_AllSpectypes(t *testing.T) {
testUUID := uuid.New()
testTime := time.Now()
tests := []struct {
name string
dataMap map[string]interface{}
validator func(*testing.T, *TestModel)
}{
{
name: "SqlString from string",
dataMap: map[string]interface{}{
"name": "John Doe",
},
validator: func(t *testing.T, m *TestModel) {
if !m.Name.Valid || m.Name.String() != "John Doe" {
t.Errorf("expected name='John Doe', got valid=%v, value=%s", m.Name.Valid, m.Name.String())
}
},
},
{
name: "SqlInt64 from int64",
dataMap: map[string]interface{}{
"age": int64(42),
},
validator: func(t *testing.T, m *TestModel) {
if !m.Age.Valid || m.Age.Int64() != 42 {
t.Errorf("expected age=42, got valid=%v, value=%d", m.Age.Valid, m.Age.Int64())
}
},
},
{
name: "SqlInt64 from string",
dataMap: map[string]interface{}{
"age": "99",
},
validator: func(t *testing.T, m *TestModel) {
if !m.Age.Valid || m.Age.Int64() != 99 {
t.Errorf("expected age=99, got valid=%v, value=%d", m.Age.Valid, m.Age.Int64())
}
},
},
{
name: "SqlFloat64 from float64",
dataMap: map[string]interface{}{
"score": float64(98.5),
},
validator: func(t *testing.T, m *TestModel) {
if !m.Score.Valid || m.Score.Float64() != 98.5 {
t.Errorf("expected score=98.5, got valid=%v, value=%f", m.Score.Valid, m.Score.Float64())
}
},
},
{
name: "SqlBool from bool",
dataMap: map[string]interface{}{
"active": true,
},
validator: func(t *testing.T, m *TestModel) {
if !m.Active.Valid || !m.Active.Bool() {
t.Errorf("expected active=true, got valid=%v, value=%v", m.Active.Valid, m.Active.Bool())
}
},
},
{
name: "SqlUUID from string",
dataMap: map[string]interface{}{
"uuid": testUUID.String(),
},
validator: func(t *testing.T, m *TestModel) {
if !m.UUID.Valid || m.UUID.UUID() != testUUID {
t.Errorf("expected uuid=%s, got valid=%v, value=%s", testUUID.String(), m.UUID.Valid, m.UUID.UUID().String())
}
},
},
{
name: "SqlTimeStamp from time.Time",
dataMap: map[string]interface{}{
"created_at": testTime,
},
validator: func(t *testing.T, m *TestModel) {
if !m.CreatedAt.Valid {
t.Errorf("expected created_at to be valid")
}
// Check if times are close enough (within a second)
diff := m.CreatedAt.Time().Sub(testTime)
if diff < -time.Second || diff > time.Second {
t.Errorf("time difference too large: %v", diff)
}
},
},
{
name: "SqlTimeStamp from string",
dataMap: map[string]interface{}{
"created_at": "2024-01-15T10:30:00",
},
validator: func(t *testing.T, m *TestModel) {
if !m.CreatedAt.Valid {
t.Errorf("expected created_at to be valid")
}
expected := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)
if m.CreatedAt.Time().Year() != expected.Year() ||
m.CreatedAt.Time().Month() != expected.Month() ||
m.CreatedAt.Time().Day() != expected.Day() {
t.Errorf("expected date 2024-01-15, got %v", m.CreatedAt.Time())
}
},
},
{
name: "SqlDate from string",
dataMap: map[string]interface{}{
"birth_date": "2000-05-20",
},
validator: func(t *testing.T, m *TestModel) {
if !m.BirthDate.Valid {
t.Errorf("expected birth_date to be valid")
}
expected := "2000-05-20"
if m.BirthDate.String() != expected {
t.Errorf("expected date=%s, got %s", expected, m.BirthDate.String())
}
},
},
{
name: "SqlTime from string",
dataMap: map[string]interface{}{
"start_time": "14:30:00",
},
validator: func(t *testing.T, m *TestModel) {
if !m.StartTime.Valid {
t.Errorf("expected start_time to be valid")
}
if m.StartTime.String() != "14:30:00" {
t.Errorf("expected time=14:30:00, got %s", m.StartTime.String())
}
},
},
{
name: "SqlJSONB from map",
dataMap: map[string]interface{}{
"metadata": map[string]interface{}{
"key1": "value1",
"key2": 123,
},
},
validator: func(t *testing.T, m *TestModel) {
if len(m.Metadata) == 0 {
t.Errorf("expected metadata to have data")
}
asMap, err := m.Metadata.AsMap()
if err != nil {
t.Fatalf("failed to convert metadata to map: %v", err)
}
if asMap["key1"] != "value1" {
t.Errorf("expected key1=value1, got %v", asMap["key1"])
}
},
},
{
name: "SqlJSONB from string",
dataMap: map[string]interface{}{
"metadata": `{"test":"data"}`,
},
validator: func(t *testing.T, m *TestModel) {
if len(m.Metadata) == 0 {
t.Errorf("expected metadata to have data")
}
asMap, err := m.Metadata.AsMap()
if err != nil {
t.Fatalf("failed to convert metadata to map: %v", err)
}
if asMap["test"] != "data" {
t.Errorf("expected test=data, got %v", asMap["test"])
}
},
},
{
name: "SqlJSONB from []byte",
dataMap: map[string]interface{}{
"metadata": []byte(`{"byte":"array"}`),
},
validator: func(t *testing.T, m *TestModel) {
if len(m.Metadata) == 0 {
t.Errorf("expected metadata to have data")
}
if string(m.Metadata) != `{"byte":"array"}` {
t.Errorf("expected {\"byte\":\"array\"}, got %s", string(m.Metadata))
}
},
},
{
name: "SqlInt16 from int16",
dataMap: map[string]interface{}{
"count16": int16(100),
},
validator: func(t *testing.T, m *TestModel) {
if !m.Count16.Valid || m.Count16.Int64() != 100 {
t.Errorf("expected count16=100, got valid=%v, value=%d", m.Count16.Valid, m.Count16.Int64())
}
},
},
{
name: "SqlInt32 from int32",
dataMap: map[string]interface{}{
"count32": int32(5000),
},
validator: func(t *testing.T, m *TestModel) {
if !m.Count32.Valid || m.Count32.Int64() != 5000 {
t.Errorf("expected count32=5000, got valid=%v, value=%d", m.Count32.Valid, m.Count32.Int64())
}
},
},
{
name: "nil values create invalid nulls",
dataMap: map[string]interface{}{
"name": nil,
"age": nil,
"active": nil,
"created_at": nil,
},
validator: func(t *testing.T, m *TestModel) {
if m.Name.Valid {
t.Error("expected name to be invalid for nil value")
}
if m.Age.Valid {
t.Error("expected age to be invalid for nil value")
}
if m.Active.Valid {
t.Error("expected active to be invalid for nil value")
}
if m.CreatedAt.Valid {
t.Error("expected created_at to be invalid for nil value")
}
},
},
{
name: "all types together",
dataMap: map[string]interface{}{
"id": int64(1),
"name": "Test User",
"age": int64(30),
"score": float64(95.7),
"active": true,
"uuid": testUUID.String(),
"created_at": "2024-01-15T10:30:00",
"birth_date": "1994-06-15",
"start_time": "09:00:00",
"metadata": map[string]interface{}{"role": "admin"},
"count16": int16(50),
"count32": int32(1000),
},
validator: func(t *testing.T, m *TestModel) {
if m.ID != 1 {
t.Errorf("expected id=1, got %d", m.ID)
}
if !m.Name.Valid || m.Name.String() != "Test User" {
t.Errorf("expected name='Test User', got valid=%v, value=%s", m.Name.Valid, m.Name.String())
}
if !m.Age.Valid || m.Age.Int64() != 30 {
t.Errorf("expected age=30, got valid=%v, value=%d", m.Age.Valid, m.Age.Int64())
}
if !m.Score.Valid || m.Score.Float64() != 95.7 {
t.Errorf("expected score=95.7, got valid=%v, value=%f", m.Score.Valid, m.Score.Float64())
}
if !m.Active.Valid || !m.Active.Bool() {
t.Errorf("expected active=true, got valid=%v, value=%v", m.Active.Valid, m.Active.Bool())
}
if !m.UUID.Valid {
t.Error("expected uuid to be valid")
}
if !m.CreatedAt.Valid {
t.Error("expected created_at to be valid")
}
if !m.BirthDate.Valid || m.BirthDate.String() != "1994-06-15" {
t.Errorf("expected birth_date=1994-06-15, got valid=%v, value=%s", m.BirthDate.Valid, m.BirthDate.String())
}
if !m.StartTime.Valid || m.StartTime.String() != "09:00:00" {
t.Errorf("expected start_time=09:00:00, got valid=%v, value=%s", m.StartTime.Valid, m.StartTime.String())
}
if len(m.Metadata) == 0 {
t.Error("expected metadata to have data")
}
if !m.Count16.Valid || m.Count16.Int64() != 50 {
t.Errorf("expected count16=50, got valid=%v, value=%d", m.Count16.Valid, m.Count16.Int64())
}
if !m.Count32.Valid || m.Count32.Int64() != 1000 {
t.Errorf("expected count32=1000, got valid=%v, value=%d", m.Count32.Valid, m.Count32.Int64())
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
model := &TestModel{}
if err := MapToStruct(tt.dataMap, model); err != nil {
t.Fatalf("MapToStruct failed: %v", err)
}
tt.validator(t, model)
})
}
}
// TestMapToStruct_PartialUpdate tests that partial updates preserve unset fields
func TestMapToStruct_PartialUpdate(t *testing.T) {
// Create initial model with some values
initial := &TestModel{
ID: 1,
Name: spectypes.NewSqlString("Original Name"),
Age: spectypes.NewSqlInt64(25),
}
// Update only the age field
partialUpdate := map[string]interface{}{
"age": int64(30),
}
// Apply partial update
if err := MapToStruct(partialUpdate, initial); err != nil {
t.Fatalf("MapToStruct failed: %v", err)
}
// Verify age was updated
if !initial.Age.Valid || initial.Age.Int64() != 30 {
t.Errorf("expected age=30, got valid=%v, value=%d", initial.Age.Valid, initial.Age.Int64())
}
// Verify name was preserved (not overwritten with zero value)
if !initial.Name.Valid || initial.Name.String() != "Original Name" {
t.Errorf("expected name='Original Name' to be preserved, got valid=%v, value=%s", initial.Name.Valid, initial.Name.String())
}
// Verify ID was preserved
if initial.ID != 1 {
t.Errorf("expected id=1 to be preserved, got %d", initial.ID)
}
}

View File

@@ -701,97 +701,130 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// Get the primary key name
pkName := reflection.GetPrimaryKeyName(model)
// First, read the existing record from the database
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := h.db.NewSelect().Model(existingRecord)
// Wrap in transaction to ensure BeforeUpdate hook is inside transaction
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
// First, read the existing record from the database
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := tx.NewSelect().Model(existingRecord).Column("*")
// Apply conditions to select
if urlID != "" {
logger.Debug("Updating by URL ID: %s", urlID)
selectQuery = selectQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), urlID)
} else if reqID != nil {
switch id := reqID.(type) {
case string:
logger.Debug("Updating by request ID: %s", id)
selectQuery = selectQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
case []string:
if len(id) > 0 {
logger.Debug("Updating by multiple IDs: %v", id)
selectQuery = selectQuery.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(pkName)), id)
// Apply conditions to select
if urlID != "" {
logger.Debug("Updating by URL ID: %s", urlID)
selectQuery = selectQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), urlID)
} else if reqID != nil {
switch id := reqID.(type) {
case string:
logger.Debug("Updating by request ID: %s", id)
selectQuery = selectQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
case []string:
if len(id) > 0 {
logger.Debug("Updating by multiple IDs: %v", id)
selectQuery = selectQuery.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(pkName)), id)
}
}
}
}
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
logger.Warn("No records found to update")
h.sendError(w, http.StatusNotFound, "not_found", "No records found to update", nil)
return
}
logger.Error("Error fetching existing record: %v", err)
h.sendError(w, http.StatusInternalServerError, "update_error", "Error fetching existing record", err)
return
}
// Convert existing record to map
existingMap := make(map[string]interface{})
jsonData, err := json.Marshal(existingRecord)
if err != nil {
logger.Error("Error marshaling existing record: %v", err)
h.sendError(w, http.StatusInternalServerError, "update_error", "Error processing existing record", err)
return
}
if err := json.Unmarshal(jsonData, &existingMap); err != nil {
logger.Error("Error unmarshaling existing record: %v", err)
h.sendError(w, http.StatusInternalServerError, "update_error", "Error processing existing record", err)
return
}
// Merge only non-null and non-empty values from the incoming request into the existing record
for key, newValue := range updates {
// Skip if the value is nil
if newValue == nil {
continue
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("no records found to update")
}
return fmt.Errorf("error fetching existing record: %w", err)
}
// Skip if the value is an empty string
if strVal, ok := newValue.(string); ok && strVal == "" {
continue
// Convert existing record to map
existingMap := make(map[string]interface{})
jsonData, err := json.Marshal(existingRecord)
if err != nil {
return fmt.Errorf("error marshaling existing record: %w", err)
}
if err := json.Unmarshal(jsonData, &existingMap); err != nil {
return fmt.Errorf("error unmarshaling existing record: %w", err)
}
// Update the existing map with the new value
existingMap[key] = newValue
}
// Build update query with merged data
query := h.db.NewUpdate().Table(tableName).SetMap(existingMap)
// Apply conditions
if urlID != "" {
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), urlID)
} else if reqID != nil {
switch id := reqID.(type) {
case string:
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
case []string:
query = query.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(pkName)), id)
// Execute BeforeUpdate hooks inside transaction
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: urlID,
Data: updates,
Writer: w,
Tx: tx,
}
}
result, err := query.Exec(ctx)
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
return fmt.Errorf("BeforeUpdate hook failed: %w", err)
}
// Use potentially modified data from hook context
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
updates = modifiedData
}
// Merge only non-null and non-empty values from the incoming request into the existing record
for key, newValue := range updates {
// Skip if the value is nil
if newValue == nil {
continue
}
// Skip if the value is an empty string
if strVal, ok := newValue.(string); ok && strVal == "" {
continue
}
// Update the existing map with the new value
existingMap[key] = newValue
}
// Build update query with merged data
query := tx.NewUpdate().Table(tableName).SetMap(existingMap)
// Apply conditions
if urlID != "" {
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), urlID)
} else if reqID != nil {
switch id := reqID.(type) {
case string:
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
case []string:
query = query.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(pkName)), id)
}
}
result, err := query.Exec(ctx)
if err != nil {
return fmt.Errorf("error updating record(s): %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("no records found to update")
}
// Execute AfterUpdate hooks inside transaction
hookCtx.Result = updates
hookCtx.Error = nil
if err := h.hooks.Execute(AfterUpdate, hookCtx); err != nil {
return fmt.Errorf("AfterUpdate hook failed: %w", err)
}
return nil
})
if err != nil {
logger.Error("Update error: %v", err)
h.sendError(w, http.StatusInternalServerError, "update_error", "Error updating record(s)", err)
if err.Error() == "no records found to update" {
h.sendError(w, http.StatusNotFound, "not_found", "No records found to update", err)
} else {
h.sendError(w, http.StatusInternalServerError, "update_error", "Error updating record(s)", err)
}
return
}
if result.RowsAffected() == 0 {
logger.Warn("No records found to update")
h.sendError(w, http.StatusNotFound, "not_found", "No records found to update", nil)
return
}
logger.Info("Successfully updated %d records", result.RowsAffected())
logger.Info("Successfully updated record(s)")
// Invalidate cache for this table
cacheTags := buildCacheTags(schema, tableName)
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
@@ -849,9 +882,11 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range updates {
if itemID, ok := item["id"]; ok {
itemIDStr := fmt.Sprintf("%v", itemID)
// First, read the existing record
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := tx.NewSelect().Model(existingRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
selectQuery := tx.NewSelect().Model(existingRecord).Column("*").Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
continue // Skip if record not found
@@ -869,6 +904,29 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
return fmt.Errorf("failed to unmarshal existing record: %w", err)
}
// Execute BeforeUpdate hooks inside transaction
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: itemIDStr,
Data: item,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
return fmt.Errorf("BeforeUpdate hook failed for ID %v: %w", itemID, err)
}
// Use potentially modified data from hook context
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
item = modifiedData
}
// Merge only non-null and non-empty values
for key, newValue := range item {
if newValue == nil {
@@ -884,6 +942,13 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
if _, err := txQuery.Exec(ctx); err != nil {
return err
}
// Execute AfterUpdate hooks inside transaction
hookCtx.Result = item
hookCtx.Error = nil
if err := h.hooks.Execute(AfterUpdate, hookCtx); err != nil {
return fmt.Errorf("AfterUpdate hook failed for ID %v: %w", itemID, err)
}
}
}
return nil
@@ -957,9 +1022,11 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
for _, item := range updates {
if itemMap, ok := item.(map[string]interface{}); ok {
if itemID, ok := itemMap["id"]; ok {
itemIDStr := fmt.Sprintf("%v", itemID)
// First, read the existing record
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := tx.NewSelect().Model(existingRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
selectQuery := tx.NewSelect().Model(existingRecord).Column("*").Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
continue // Skip if record not found
@@ -977,6 +1044,29 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
return fmt.Errorf("failed to unmarshal existing record: %w", err)
}
// Execute BeforeUpdate hooks inside transaction
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: itemIDStr,
Data: itemMap,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
return fmt.Errorf("BeforeUpdate hook failed for ID %v: %w", itemID, err)
}
// Use potentially modified data from hook context
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
itemMap = modifiedData
}
// Merge only non-null and non-empty values
for key, newValue := range itemMap {
if newValue == nil {
@@ -992,6 +1082,14 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
if _, err := txQuery.Exec(ctx); err != nil {
return err
}
// Execute AfterUpdate hooks inside transaction
hookCtx.Result = itemMap
hookCtx.Error = nil
if err := h.hooks.Execute(AfterUpdate, hookCtx); err != nil {
return fmt.Errorf("AfterUpdate hook failed for ID %v: %w", itemID, err)
}
list = append(list, item)
}
}
@@ -1453,30 +1551,7 @@ func isNullable(field reflect.StructField) bool {
// GetRelationshipInfo implements common.RelationshipInfoProvider interface
func (h *Handler) GetRelationshipInfo(modelType reflect.Type, relationName string) *common.RelationshipInfo {
info := h.getRelationshipInfo(modelType, relationName)
if info == nil {
return nil
}
// Convert internal type to common type
return &common.RelationshipInfo{
FieldName: info.fieldName,
JSONName: info.jsonName,
RelationType: info.relationType,
ForeignKey: info.foreignKey,
References: info.references,
JoinTable: info.joinTable,
RelatedModel: info.relatedModel,
}
}
type relationshipInfo struct {
fieldName string
jsonName string
relationType string // "belongsTo", "hasMany", "hasOne", "many2many"
foreignKey string
references string
joinTable string
relatedModel interface{}
return common.GetRelationshipInfo(modelType, relationName)
}
func (h *Handler) applyPreloads(model interface{}, query common.SelectQuery, preloads []common.PreloadOption) (common.SelectQuery, error) {
@@ -1496,7 +1571,7 @@ func (h *Handler) applyPreloads(model interface{}, query common.SelectQuery, pre
for idx := range preloads {
preload := preloads[idx]
logger.Debug("Processing preload for relation: %s", preload.Relation)
relInfo := h.getRelationshipInfo(modelType, preload.Relation)
relInfo := common.GetRelationshipInfo(modelType, preload.Relation)
if relInfo == nil {
logger.Warn("Relation %s not found in model", preload.Relation)
continue
@@ -1504,7 +1579,7 @@ func (h *Handler) applyPreloads(model interface{}, query common.SelectQuery, pre
// Use the field name (capitalized) for ORM preloading
// ORMs like GORM and Bun expect the struct field name, not the JSON name
relationFieldName := relInfo.fieldName
relationFieldName := relInfo.FieldName
// Validate and fix WHERE clause to ensure it contains the relation prefix
if len(preload.Where) > 0 {
@@ -1547,13 +1622,13 @@ func (h *Handler) applyPreloads(model interface{}, query common.SelectQuery, pre
copy(columns, preload.Columns)
// Add foreign key if not already present
if relInfo.foreignKey != "" {
if relInfo.ForeignKey != "" {
// Convert struct field name (e.g., DepartmentID) to snake_case (e.g., department_id)
foreignKeyColumn := toSnakeCase(relInfo.foreignKey)
foreignKeyColumn := toSnakeCase(relInfo.ForeignKey)
hasForeignKey := false
for _, col := range columns {
if col == foreignKeyColumn || col == relInfo.foreignKey {
if col == foreignKeyColumn || col == relInfo.ForeignKey {
hasForeignKey = true
break
}
@@ -1599,58 +1674,6 @@ func (h *Handler) applyPreloads(model interface{}, query common.SelectQuery, pre
return query, nil
}
func (h *Handler) getRelationshipInfo(modelType reflect.Type, relationName string) *relationshipInfo {
// Ensure we have a struct type
if modelType == nil || modelType.Kind() != reflect.Struct {
logger.Warn("Cannot get relationship info from non-struct type: %v", modelType)
return nil
}
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
jsonTag := field.Tag.Get("json")
jsonName := strings.Split(jsonTag, ",")[0]
if jsonName == relationName {
gormTag := field.Tag.Get("gorm")
info := &relationshipInfo{
fieldName: field.Name,
jsonName: jsonName,
}
// Parse GORM tag to determine relationship type and keys
if strings.Contains(gormTag, "foreignKey") {
info.foreignKey = h.extractTagValue(gormTag, "foreignKey")
info.references = h.extractTagValue(gormTag, "references")
// Determine if it's belongsTo or hasMany/hasOne
if field.Type.Kind() == reflect.Slice {
info.relationType = "hasMany"
} else if field.Type.Kind() == reflect.Ptr || field.Type.Kind() == reflect.Struct {
info.relationType = "belongsTo"
}
} else if strings.Contains(gormTag, "many2many") {
info.relationType = "many2many"
info.joinTable = h.extractTagValue(gormTag, "many2many")
}
return info
}
}
return nil
}
func (h *Handler) extractTagValue(tag, key string) string {
parts := strings.Split(tag, ";")
for _, part := range parts {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, key+":") {
return strings.TrimPrefix(part, key+":")
}
}
return ""
}
// toSnakeCase converts a PascalCase or camelCase string to snake_case
func toSnakeCase(s string) string {
var result strings.Builder

View File

@@ -269,8 +269,6 @@ func TestToSnakeCase(t *testing.T) {
}
func TestExtractTagValue(t *testing.T) {
handler := NewHandler(nil, nil)
tests := []struct {
name string
tag string
@@ -311,9 +309,9 @@ func TestExtractTagValue(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := handler.extractTagValue(tt.tag, tt.key)
result := common.ExtractTagValue(tt.tag, tt.key)
if result != tt.expected {
t.Errorf("extractTagValue(%q, %q) = %q, expected %q", tt.tag, tt.key, result, tt.expected)
t.Errorf("ExtractTagValue(%q, %q) = %q, expected %q", tt.tag, tt.key, result, tt.expected)
}
})
}

View File

@@ -50,8 +50,9 @@ func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, authMiddleware Midd
openAPIHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
corsConfig := common.DefaultCORSConfig()
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(r)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
handler.HandleOpenAPI(respAdapter, reqAdapter)
})
muxRouter.Handle("/openapi", openAPIHandler).Methods("GET", "OPTIONS")
@@ -98,7 +99,8 @@ func createMuxHandler(handler *Handler, schema, entity, idParam string) http.Han
// Set CORS headers
corsConfig := common.DefaultCORSConfig()
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(r)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
vars := make(map[string]string)
vars["schema"] = schema
@@ -106,7 +108,7 @@ func createMuxHandler(handler *Handler, schema, entity, idParam string) http.Han
if idParam != "" {
vars["id"] = mux.Vars(r)[idParam]
}
reqAdapter := router.NewHTTPRequest(r)
handler.Handle(respAdapter, reqAdapter, vars)
}
}
@@ -117,7 +119,8 @@ func createMuxGetHandler(handler *Handler, schema, entity, idParam string) http.
// Set CORS headers
corsConfig := common.DefaultCORSConfig()
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(r)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
vars := make(map[string]string)
vars["schema"] = schema
@@ -125,7 +128,7 @@ func createMuxGetHandler(handler *Handler, schema, entity, idParam string) http.
if idParam != "" {
vars["id"] = mux.Vars(r)[idParam]
}
reqAdapter := router.NewHTTPRequest(r)
handler.HandleGet(respAdapter, reqAdapter, vars)
}
}
@@ -137,13 +140,14 @@ func createMuxOptionsHandler(handler *Handler, schema, entity string, allowedMet
corsConfig := common.DefaultCORSConfig()
corsConfig.AllowedMethods = allowedMethods
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(r)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
// Return metadata in the OPTIONS response body
vars := make(map[string]string)
vars["schema"] = schema
vars["entity"] = entity
reqAdapter := router.NewHTTPRequest(r)
handler.HandleGet(respAdapter, reqAdapter, vars)
}
}
@@ -222,15 +226,16 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// Add global /openapi route
r.Handle("GET", "/openapi", func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
handler.HandleOpenAPI(respAdapter, reqAdapter)
return nil
})
r.Handle("OPTIONS", "/openapi", func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
return nil
})
@@ -253,12 +258,13 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// POST route without ID
r.Handle("POST", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewHTTPRequest(req.Request)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
@@ -266,13 +272,14 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// POST route with ID
r.Handle("POST", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
"id": req.Param("id"),
}
reqAdapter := router.NewHTTPRequest(req.Request)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
@@ -280,12 +287,13 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// GET route without ID
r.Handle("GET", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewHTTPRequest(req.Request)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
@@ -293,13 +301,14 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// GET route with ID
r.Handle("GET", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
"id": req.Param("id"),
}
reqAdapter := router.NewHTTPRequest(req.Request)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
@@ -307,14 +316,15 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// OPTIONS route without ID (returns metadata)
r.Handle("OPTIONS", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request)
optionsCorsConfig := corsConfig
optionsCorsConfig.AllowedMethods = []string{"GET", "POST", "OPTIONS"}
common.SetCORSHeaders(respAdapter, optionsCorsConfig)
common.SetCORSHeaders(respAdapter, reqAdapter, optionsCorsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewHTTPRequest(req.Request)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
@@ -322,14 +332,15 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// OPTIONS route with ID (returns metadata)
r.Handle("OPTIONS", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request)
optionsCorsConfig := corsConfig
optionsCorsConfig.AllowedMethods = []string{"POST", "OPTIONS"}
common.SetCORSHeaders(respAdapter, optionsCorsConfig)
common.SetCORSHeaders(respAdapter, reqAdapter, optionsCorsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewHTTPRequest(req.Request)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})

View File

@@ -766,7 +766,7 @@ func (h *Handler) applyPreloadWithRecursion(query common.SelectQuery, preload co
// Apply ComputedQL fields if any
if len(preload.ComputedQL) > 0 {
// Get the base table name from the related model
baseTableName := getTableNameFromModel(relatedModel)
baseTableName := common.GetTableNameFromModel(relatedModel)
// Convert the preload relation path to the appropriate alias format
// This is ORM-specific. Currently we only support Bun's format.
@@ -777,7 +777,7 @@ func (h *Handler) applyPreloadWithRecursion(query common.SelectQuery, preload co
underlyingType := fmt.Sprintf("%T", h.db.GetUnderlyingDB())
if strings.Contains(underlyingType, "bun.DB") {
// Use Bun's alias format: lowercase with double underscores
preloadAlias = relationPathToBunAlias(preload.Relation)
preloadAlias = common.RelationPathToBunAlias(preload.Relation)
}
// For GORM: GORM doesn't use the same alias format, and this fix
// may not be needed since GORM handles preloads differently
@@ -792,7 +792,7 @@ func (h *Handler) applyPreloadWithRecursion(query common.SelectQuery, preload co
// levels of recursive/nested preloads
adjustedExpr := colExpr
if baseTableName != "" && preloadAlias != "" {
adjustedExpr = replaceTableReferencesInSQL(colExpr, baseTableName, preloadAlias)
adjustedExpr = common.ReplaceTableReferencesInSQL(colExpr, baseTableName, preloadAlias)
if adjustedExpr != colExpr {
logger.Debug("Adjusted computed column expression for %s: '%s' -> '%s'",
colName, colExpr, adjustedExpr)
@@ -903,73 +903,6 @@ func (h *Handler) applyPreloadWithRecursion(query common.SelectQuery, preload co
return query
}
// relationPathToBunAlias converts a relation path like "MAL.MAL.DEF" to the Bun alias format "mal__mal__def"
// Bun generates aliases for nested relations by lowercasing and replacing dots with double underscores
func relationPathToBunAlias(relationPath string) string {
if relationPath == "" {
return ""
}
// Convert to lowercase and replace dots with double underscores
alias := strings.ToLower(relationPath)
alias = strings.ReplaceAll(alias, ".", "__")
return alias
}
// replaceTableReferencesInSQL replaces references to a base table name in a SQL expression
// with the appropriate alias for the current preload level
// For example, if baseTableName is "mastertaskitem" and targetAlias is "mal__mal",
// it will replace "mastertaskitem.rid_mastertaskitem" with "mal__mal.rid_mastertaskitem"
func replaceTableReferencesInSQL(sqlExpr, baseTableName, targetAlias string) string {
if sqlExpr == "" || baseTableName == "" || targetAlias == "" {
return sqlExpr
}
// Replace both quoted and unquoted table references
// Handle patterns like: tablename.column, "tablename".column, tablename."column", "tablename"."column"
// Pattern 1: tablename.column (unquoted)
result := strings.ReplaceAll(sqlExpr, baseTableName+".", targetAlias+".")
// Pattern 2: "tablename".column or "tablename"."column" (quoted table name)
result = strings.ReplaceAll(result, "\""+baseTableName+"\".", "\""+targetAlias+"\".")
return result
}
// getTableNameFromModel extracts the table name from a model
// It checks the bun tag first, then falls back to converting the struct name to snake_case
func getTableNameFromModel(model interface{}) string {
if model == nil {
return ""
}
modelType := reflect.TypeOf(model)
// Unwrap pointers
for modelType != nil && modelType.Kind() == reflect.Ptr {
modelType = modelType.Elem()
}
if modelType == nil || modelType.Kind() != reflect.Struct {
return ""
}
// Look for bun tag on embedded BaseModel
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
if field.Anonymous {
bunTag := field.Tag.Get("bun")
if strings.HasPrefix(bunTag, "table:") {
return strings.TrimPrefix(bunTag, "table:")
}
}
}
// Fallback: convert struct name to lowercase (simple heuristic)
// This handles cases like "MasterTaskItem" -> "mastertaskitem"
return strings.ToLower(modelType.Name())
}
func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, data interface{}, options ExtendedRequestOptions) {
// Capture panics and return error response
defer func() {
@@ -1177,30 +1110,6 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
logger.Info("Updating record in %s.%s", schema, entity)
// Execute BeforeUpdate hooks
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
TableName: tableName,
Tx: h.db,
Model: model,
Options: options,
ID: id,
Data: data,
Writer: w,
}
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
logger.Error("BeforeUpdate hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
}
// Use potentially modified data from hook context
data = hookCtx.Data
// Convert data to map
dataMap, ok := data.(map[string]interface{})
if !ok {
@@ -1234,6 +1143,9 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// Variable to store the updated record
var updatedRecord interface{}
// Declare hook context to be used inside and outside transaction
var hookCtx *HookContext
// Process nested relations if present
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
// Create temporary nested processor with transaction
@@ -1241,7 +1153,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// First, read the existing record from the database
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := tx.NewSelect().Model(existingRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
selectQuery := tx.NewSelect().Model(existingRecord).Column("*").Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("record not found with ID: %v", targetID)
@@ -1271,6 +1183,30 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
nestedRelations = relations
}
// Execute BeforeUpdate hooks inside transaction
hookCtx = &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
TableName: tableName,
Tx: tx,
Model: model,
Options: options,
ID: id,
Data: dataMap,
Writer: w,
}
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
return fmt.Errorf("BeforeUpdate hook failed: %w", err)
}
// Use potentially modified data from hook context
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
dataMap = modifiedData
}
// Merge only non-null and non-empty values from the incoming request into the existing record
for key, newValue := range dataMap {
// Skip if the value is nil
@@ -2570,10 +2506,10 @@ func (h *Handler) filterExtendedOptions(validator *common.ColumnValidator, optio
filteredExpand := expand
// Get the relationship info for this expand relation
relInfo := h.getRelationshipInfo(modelType, expand.Relation)
if relInfo != nil && relInfo.relatedModel != nil {
relInfo := common.GetRelationshipInfo(modelType, expand.Relation)
if relInfo != nil && relInfo.RelatedModel != nil {
// Create a validator for the related model
expandValidator := common.NewColumnValidator(relInfo.relatedModel)
expandValidator := common.NewColumnValidator(relInfo.RelatedModel)
// Filter columns using the related model's validator
filteredExpand.Columns = expandValidator.FilterValidColumns(expand.Columns)
@@ -2650,110 +2586,7 @@ func (h *Handler) shouldUseNestedProcessor(data map[string]interface{}, model in
// GetRelationshipInfo implements common.RelationshipInfoProvider interface
func (h *Handler) GetRelationshipInfo(modelType reflect.Type, relationName string) *common.RelationshipInfo {
info := h.getRelationshipInfo(modelType, relationName)
if info == nil {
return nil
}
// Convert internal type to common type
return &common.RelationshipInfo{
FieldName: info.fieldName,
JSONName: info.jsonName,
RelationType: info.relationType,
ForeignKey: info.foreignKey,
References: info.references,
JoinTable: info.joinTable,
RelatedModel: info.relatedModel,
}
}
type relationshipInfo struct {
fieldName string
jsonName string
relationType string // "belongsTo", "hasMany", "hasOne", "many2many"
foreignKey string
references string
joinTable string
relatedModel interface{}
}
func (h *Handler) getRelationshipInfo(modelType reflect.Type, relationName string) *relationshipInfo {
// Ensure we have a struct type
if modelType == nil || modelType.Kind() != reflect.Struct {
logger.Warn("Cannot get relationship info from non-struct type: %v", modelType)
return nil
}
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
jsonTag := field.Tag.Get("json")
jsonName := strings.Split(jsonTag, ",")[0]
if jsonName == relationName {
gormTag := field.Tag.Get("gorm")
info := &relationshipInfo{
fieldName: field.Name,
jsonName: jsonName,
}
// Parse GORM tag to determine relationship type and keys
if strings.Contains(gormTag, "foreignKey") {
info.foreignKey = h.extractTagValue(gormTag, "foreignKey")
info.references = h.extractTagValue(gormTag, "references")
// Determine if it's belongsTo or hasMany/hasOne
if field.Type.Kind() == reflect.Slice {
info.relationType = "hasMany"
// Get the element type for slice
elemType := field.Type.Elem()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.relatedModel = reflect.New(elemType).Elem().Interface()
}
} else if field.Type.Kind() == reflect.Ptr || field.Type.Kind() == reflect.Struct {
info.relationType = "belongsTo"
elemType := field.Type
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.relatedModel = reflect.New(elemType).Elem().Interface()
}
}
} else if strings.Contains(gormTag, "many2many") {
info.relationType = "many2many"
info.joinTable = h.extractTagValue(gormTag, "many2many")
// Get the element type for many2many (always slice)
if field.Type.Kind() == reflect.Slice {
elemType := field.Type.Elem()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.relatedModel = reflect.New(elemType).Elem().Interface()
}
}
} else {
// Field has no GORM relationship tags, so it's not a relation
return nil
}
return info
}
}
return nil
}
func (h *Handler) extractTagValue(tag, key string) string {
parts := strings.Split(tag, ";")
for _, part := range parts {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, key+":") {
return strings.TrimPrefix(part, key+":")
}
}
return ""
return common.GetRelationshipInfo(modelType, relationName)
}
// HandleOpenAPI generates and returns the OpenAPI specification

View File

@@ -354,6 +354,12 @@ func (h *Handler) parseSearchOp(options *ExtendedRequestOptions, headerKey, valu
operator := parts[0]
colName := parts[1]
if strings.HasPrefix(colName, "cql") {
// Computed column - Will not filter on it
logger.Warn("Search operators on computed columns are not supported: %s", colName)
return
}
// Map operator names to filter operators
filterOp := h.mapSearchOperator(colName, operator, value)

View File

@@ -103,8 +103,9 @@ func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, authMiddleware Midd
openAPIHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
corsConfig := common.DefaultCORSConfig()
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(r)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
handler.HandleOpenAPI(respAdapter, reqAdapter)
})
muxRouter.Handle("/openapi", openAPIHandler).Methods("GET", "OPTIONS")
@@ -161,7 +162,8 @@ func createMuxHandler(handler *Handler, schema, entity, idParam string) http.Han
// Set CORS headers
corsConfig := common.DefaultCORSConfig()
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(r)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
vars := make(map[string]string)
vars["schema"] = schema
@@ -169,7 +171,7 @@ func createMuxHandler(handler *Handler, schema, entity, idParam string) http.Han
if idParam != "" {
vars["id"] = mux.Vars(r)[idParam]
}
reqAdapter := router.NewHTTPRequest(r)
handler.Handle(respAdapter, reqAdapter, vars)
}
}
@@ -180,7 +182,8 @@ func createMuxGetHandler(handler *Handler, schema, entity, idParam string) http.
// Set CORS headers
corsConfig := common.DefaultCORSConfig()
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(r)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
vars := make(map[string]string)
vars["schema"] = schema
@@ -188,7 +191,7 @@ func createMuxGetHandler(handler *Handler, schema, entity, idParam string) http.
if idParam != "" {
vars["id"] = mux.Vars(r)[idParam]
}
reqAdapter := router.NewHTTPRequest(r)
handler.HandleGet(respAdapter, reqAdapter, vars)
}
}
@@ -200,13 +203,14 @@ func createMuxOptionsHandler(handler *Handler, schema, entity string, allowedMet
corsConfig := common.DefaultCORSConfig()
corsConfig.AllowedMethods = allowedMethods
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(r)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
// Return metadata in the OPTIONS response body
vars := make(map[string]string)
vars["schema"] = schema
vars["entity"] = entity
reqAdapter := router.NewHTTPRequest(r)
handler.HandleGet(respAdapter, reqAdapter, vars)
}
}
@@ -285,15 +289,8 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// Add global /openapi route
r.Handle("GET", "/openapi", func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
handler.HandleOpenAPI(respAdapter, reqAdapter)
return nil
})
r.Handle("OPTIONS", "/openapi", func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
return nil
})
@@ -317,24 +314,26 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// GET and POST for /{schema}/{entity}
r.Handle("GET", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewBunRouterRequest(req)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
r.Handle("POST", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewBunRouterRequest(req)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
@@ -342,65 +341,70 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// GET, POST, PUT, PATCH, DELETE for /{schema}/{entity}/:id
r.Handle("GET", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
"id": req.Param("id"),
}
reqAdapter := router.NewBunRouterRequest(req)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
r.Handle("POST", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
"id": req.Param("id"),
}
reqAdapter := router.NewBunRouterRequest(req)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
r.Handle("PUT", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
"id": req.Param("id"),
}
reqAdapter := router.NewBunRouterRequest(req)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
r.Handle("PATCH", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
"id": req.Param("id"),
}
reqAdapter := router.NewBunRouterRequest(req)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
r.Handle("DELETE", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
"id": req.Param("id"),
}
reqAdapter := router.NewBunRouterRequest(req)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
@@ -408,12 +412,13 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// Metadata endpoint
r.Handle("GET", metadataPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewBunRouterRequest(req)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
@@ -421,14 +426,15 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// OPTIONS route without ID (returns metadata)
r.Handle("OPTIONS", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req)
optionsCorsConfig := corsConfig
optionsCorsConfig.AllowedMethods = []string{"GET", "POST", "OPTIONS"}
common.SetCORSHeaders(respAdapter, optionsCorsConfig)
common.SetCORSHeaders(respAdapter, reqAdapter, optionsCorsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewBunRouterRequest(req)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
@@ -436,14 +442,15 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// OPTIONS route with ID (returns metadata)
r.Handle("OPTIONS", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req)
optionsCorsConfig := corsConfig
optionsCorsConfig.AllowedMethods = []string{"GET", "PUT", "PATCH", "DELETE", "POST", "OPTIONS"}
common.SetCORSHeaders(respAdapter, optionsCorsConfig)
common.SetCORSHeaders(respAdapter, reqAdapter, optionsCorsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewBunRouterRequest(req)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})

View File

@@ -2,6 +2,8 @@ package restheadspec
import (
"testing"
"github.com/bitechdev/ResolveSpec/pkg/common"
)
func TestParseModelName(t *testing.T) {
@@ -112,3 +114,88 @@ func TestNewStandardBunRouter(t *testing.T) {
t.Error("Expected router to be created, got nil")
}
}
func TestExtractTagValue(t *testing.T) {
tests := []struct {
name string
tag string
key string
expected string
}{
{
name: "Extract existing key",
tag: "json:name;validate:required",
key: "json",
expected: "name",
},
{
name: "Extract key with spaces",
tag: "json:name ; validate:required",
key: "validate",
expected: "required",
},
{
name: "Extract key at end",
tag: "json:name;validate:required;db:column_name",
key: "db",
expected: "column_name",
},
{
name: "Extract key at beginning",
tag: "primary:true;json:id;db:user_id",
key: "primary",
expected: "true",
},
{
name: "Key not found",
tag: "json:name;validate:required",
key: "db",
expected: "",
},
{
name: "Empty tag",
tag: "",
key: "json",
expected: "",
},
{
name: "Single key-value pair",
tag: "json:name",
key: "json",
expected: "name",
},
{
name: "Key with empty value",
tag: "json:;validate:required",
key: "json",
expected: "",
},
{
name: "Key with complex value",
tag: "json:user_name,omitempty;validate:required,min=3",
key: "json",
expected: "user_name,omitempty",
},
{
name: "Multiple semicolons",
tag: "json:name;;validate:required",
key: "validate",
expected: "required",
},
{
name: "BUN Tag",
tag: "rel:has-many,join:rid_hub=rid_hub_child",
key: "join",
expected: "rid_hub=rid_hub_child",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := common.ExtractTagValue(tt.tag, tt.key)
if result != tt.expected {
t.Errorf("ExtractTagValue(%q, %q) = %q; want %q", tt.tag, tt.key, result, tt.expected)
}
})
}
}