mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2025-12-29 15:54:26 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
456c165814 | ||
|
|
850d7b546c | ||
|
|
a44ef90d7c | ||
|
|
8b7db5b31a |
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/uptrace/bun"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/reflection"
|
||||
)
|
||||
|
||||
// BunAdapter adapts Bun to work with our Database interface
|
||||
@@ -353,25 +355,45 @@ func (b *BunInsertQuery) Exec(ctx context.Context) (common.Result, error) {
|
||||
// BunUpdateQuery implements UpdateQuery for Bun
|
||||
type BunUpdateQuery struct {
|
||||
query *bun.UpdateQuery
|
||||
model interface{}
|
||||
}
|
||||
|
||||
func (b *BunUpdateQuery) Model(model interface{}) common.UpdateQuery {
|
||||
b.query = b.query.Model(model)
|
||||
b.model = model
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BunUpdateQuery) Table(table string) common.UpdateQuery {
|
||||
b.query = b.query.Table(table)
|
||||
if b.model == nil {
|
||||
// Try to get table name from table string if model is not set
|
||||
|
||||
model, err := modelregistry.GetModelByName(table)
|
||||
if err == nil {
|
||||
b.model = model
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BunUpdateQuery) Set(column string, value interface{}) common.UpdateQuery {
|
||||
// Validate column is writable if model is set
|
||||
if b.model != nil && !reflection.IsColumnWritable(b.model, column) {
|
||||
// Skip scan-only columns
|
||||
return b
|
||||
}
|
||||
b.query = b.query.Set(column+" = ?", value)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BunUpdateQuery) SetMap(values map[string]interface{}) common.UpdateQuery {
|
||||
for column, value := range values {
|
||||
// Validate column is writable if model is set
|
||||
if b.model != nil && !reflection.IsColumnWritable(b.model, column) {
|
||||
// Skip scan-only columns
|
||||
continue
|
||||
}
|
||||
b.query = b.query.Set(column+" = ?", value)
|
||||
}
|
||||
return b
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/reflection"
|
||||
)
|
||||
|
||||
// GormAdapter adapts GORM to work with our Database interface
|
||||
@@ -97,6 +99,7 @@ func (g *GormSelectQuery) Table(table string) common.SelectQuery {
|
||||
g.db = g.db.Table(table)
|
||||
// Check if the table name contains schema (e.g., "schema.table")
|
||||
g.schema, g.tableName = parseTableName(table)
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
@@ -339,10 +342,23 @@ func (g *GormUpdateQuery) Model(model interface{}) common.UpdateQuery {
|
||||
|
||||
func (g *GormUpdateQuery) Table(table string) common.UpdateQuery {
|
||||
g.db = g.db.Table(table)
|
||||
if g.model == nil {
|
||||
// Try to get table name from table string if model is not set
|
||||
model, err := modelregistry.GetModelByName(table)
|
||||
if err == nil {
|
||||
g.model = model
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *GormUpdateQuery) Set(column string, value interface{}) common.UpdateQuery {
|
||||
// Validate column is writable if model is set
|
||||
if g.model != nil && !reflection.IsColumnWritable(g.model, column) {
|
||||
// Skip read-only columns
|
||||
return g
|
||||
}
|
||||
|
||||
if g.updates == nil {
|
||||
g.updates = make(map[string]interface{})
|
||||
}
|
||||
@@ -353,7 +369,18 @@ func (g *GormUpdateQuery) Set(column string, value interface{}) common.UpdateQue
|
||||
}
|
||||
|
||||
func (g *GormUpdateQuery) SetMap(values map[string]interface{}) common.UpdateQuery {
|
||||
g.updates = values
|
||||
// Filter out read-only columns if model is set
|
||||
if g.model != nil {
|
||||
filteredValues := make(map[string]interface{})
|
||||
for column, value := range values {
|
||||
if reflection.IsColumnWritable(g.model, column) {
|
||||
filteredValues[column] = value
|
||||
}
|
||||
}
|
||||
g.updates = filteredValues
|
||||
} else {
|
||||
g.updates = values
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
|
||||
161
pkg/common/adapters/database/update_validation_test.go
Normal file
161
pkg/common/adapters/database/update_validation_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/reflection"
|
||||
)
|
||||
|
||||
// Test models for bun
|
||||
type BunTestModel struct {
|
||||
ID int `bun:"id,pk"`
|
||||
Name string `bun:"name"`
|
||||
Email string `bun:"email"`
|
||||
ComputedCol string `bun:"computed_col,scanonly"`
|
||||
}
|
||||
|
||||
// Test models for gorm
|
||||
type GormTestModel struct {
|
||||
ID int `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
Email string `gorm:"column:email"`
|
||||
ReadOnlyCol string `gorm:"column:readonly_col;->"`
|
||||
NoWriteCol string `gorm:"column:nowrite_col;<-:false"`
|
||||
}
|
||||
|
||||
func TestIsColumnWritable_Bun(t *testing.T) {
|
||||
model := &BunTestModel{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
columnName string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "writable column - id",
|
||||
columnName: "id",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "writable column - name",
|
||||
columnName: "name",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "writable column - email",
|
||||
columnName: "email",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "scanonly column should not be writable",
|
||||
columnName: "computed_col",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "non-existent column should be writable (dynamic)",
|
||||
columnName: "nonexistent",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := reflection.IsColumnWritable(model, tt.columnName)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsColumnWritable(%q) = %v, want %v", tt.columnName, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsColumnWritable_Gorm(t *testing.T) {
|
||||
model := &GormTestModel{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
columnName string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "writable column - id",
|
||||
columnName: "id",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "writable column - name",
|
||||
columnName: "name",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "writable column - email",
|
||||
columnName: "email",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "read-only column with -> should not be writable",
|
||||
columnName: "readonly_col",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "column with <-:false should not be writable",
|
||||
columnName: "nowrite_col",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "non-existent column should be writable (dynamic)",
|
||||
columnName: "nonexistent",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := reflection.IsColumnWritable(model, tt.columnName)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsColumnWritable(%q) = %v, want %v", tt.columnName, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBunUpdateQuery_SetMap_FiltersScanOnly(t *testing.T) {
|
||||
// Note: This is a unit test for the validation logic only.
|
||||
// We can't fully test the bun query without a database connection,
|
||||
// but we've verified the validation logic in TestIsColumnWritable_Bun
|
||||
t.Skip("Skipping integration test - validation logic tested in TestIsColumnWritable_Bun")
|
||||
}
|
||||
|
||||
func TestGormUpdateQuery_SetMap_FiltersReadOnly(t *testing.T) {
|
||||
model := &GormTestModel{}
|
||||
query := &GormUpdateQuery{
|
||||
model: model,
|
||||
}
|
||||
|
||||
// SetMap should filter out read-only columns
|
||||
values := map[string]interface{}{
|
||||
"name": "John",
|
||||
"email": "john@example.com",
|
||||
"readonly_col": "should_be_filtered",
|
||||
"nowrite_col": "should_also_be_filtered",
|
||||
}
|
||||
|
||||
query.SetMap(values)
|
||||
|
||||
// Check that the updates map only contains writable columns
|
||||
if updates, ok := query.updates.(map[string]interface{}); ok {
|
||||
if _, exists := updates["readonly_col"]; exists {
|
||||
t.Error("readonly_col should have been filtered out")
|
||||
}
|
||||
if _, exists := updates["nowrite_col"]; exists {
|
||||
t.Error("nowrite_col should have been filtered out")
|
||||
}
|
||||
if _, exists := updates["name"]; !exists {
|
||||
t.Error("name should be in updates")
|
||||
}
|
||||
if _, exists := updates["email"]; !exists {
|
||||
t.Error("email should be in updates")
|
||||
}
|
||||
} else {
|
||||
t.Error("updates should be a map[string]interface{}")
|
||||
}
|
||||
}
|
||||
@@ -378,8 +378,16 @@ func (p *NestedCUDProcessor) getTableNameForModel(model interface{}, defaultName
|
||||
}
|
||||
|
||||
// ShouldUseNestedProcessor determines if we should use nested CUD processing
|
||||
// It checks if the data contains nested relations or a _request field
|
||||
// It recursively checks if the data contains:
|
||||
// 1. A _request field at any level, OR
|
||||
// 2. Nested relations that themselves contain further nested relations or _request fields
|
||||
// This ensures nested processing is only used when there are deeply nested operations
|
||||
func ShouldUseNestedProcessor(data map[string]interface{}, model interface{}, relationshipHelper RelationshipInfoProvider) bool {
|
||||
return shouldUseNestedProcessorDepth(data, model, relationshipHelper, 0)
|
||||
}
|
||||
|
||||
// shouldUseNestedProcessorDepth is the internal recursive implementation with depth tracking
|
||||
func shouldUseNestedProcessorDepth(data map[string]interface{}, model interface{}, relationshipHelper RelationshipInfoProvider, depth int) bool {
|
||||
// Check for _request field
|
||||
if _, hasCRUDRequest := data["_request"]; hasCRUDRequest {
|
||||
return true
|
||||
@@ -407,19 +415,33 @@ func ShouldUseNestedProcessor(data map[string]interface{}, model interface{}, re
|
||||
if relInfo != nil {
|
||||
// Check if the value is actually nested data (object or array)
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}:
|
||||
//logger.Debug("Found nested relation field: %s", key)
|
||||
return ShouldUseNestedProcessor(v, relInfo.RelatedModel, relationshipHelper)
|
||||
case []interface{}, []map[string]interface{}:
|
||||
//logger.Debug("Found nested relation field: %s", key)
|
||||
for _, item := range v.([]interface{}) {
|
||||
if itemMap, ok := item.(map[string]interface{}); ok {
|
||||
if ShouldUseNestedProcessor(itemMap, relInfo.RelatedModel, relationshipHelper) {
|
||||
case map[string]interface{}, []interface{}, []map[string]interface{}:
|
||||
// If we're already at a nested level (depth > 0) and found a relation,
|
||||
// that means we have multi-level nesting, so return true
|
||||
if depth > 0 {
|
||||
return true
|
||||
}
|
||||
// At depth 0, recurse to check if the nested data has further nesting
|
||||
switch typedValue := v.(type) {
|
||||
case map[string]interface{}:
|
||||
if shouldUseNestedProcessorDepth(typedValue, relInfo.RelatedModel, relationshipHelper, depth+1) {
|
||||
return true
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range typedValue {
|
||||
if itemMap, ok := item.(map[string]interface{}); ok {
|
||||
if shouldUseNestedProcessorDepth(itemMap, relInfo.RelatedModel, relationshipHelper, depth+1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
case []map[string]interface{}:
|
||||
for _, itemMap := range typedValue {
|
||||
if shouldUseNestedProcessorDepth(itemMap, relInfo.RelatedModel, relationshipHelper, depth+1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ var defaultRegistry = &DefaultModelRegistry{
|
||||
models: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
// Global list of registries (searched in order)
|
||||
var registries = []*DefaultModelRegistry{defaultRegistry}
|
||||
var registriesMutex sync.RWMutex
|
||||
|
||||
// NewModelRegistry creates a new model registry
|
||||
func NewModelRegistry() *DefaultModelRegistry {
|
||||
return &DefaultModelRegistry{
|
||||
@@ -24,6 +28,18 @@ func NewModelRegistry() *DefaultModelRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
func SetDefaultRegistry(registry *DefaultModelRegistry) {
|
||||
defaultRegistry = registry
|
||||
}
|
||||
|
||||
// AddRegistry adds a registry to the global list of registries
|
||||
// Registries are searched in the order they were added
|
||||
func AddRegistry(registry *DefaultModelRegistry) {
|
||||
registriesMutex.Lock()
|
||||
defer registriesMutex.Unlock()
|
||||
registries = append(registries, registry)
|
||||
}
|
||||
|
||||
func (r *DefaultModelRegistry) RegisterModel(name string, model interface{}) error {
|
||||
r.mutex.Lock()
|
||||
defer r.mutex.Unlock()
|
||||
@@ -107,9 +123,19 @@ func RegisterModel(model interface{}, name string) error {
|
||||
return defaultRegistry.RegisterModel(name, model)
|
||||
}
|
||||
|
||||
// GetModelByName retrieves a model from the default global registry by name
|
||||
// GetModelByName retrieves a model by searching through all registries in order
|
||||
// Returns the first match found
|
||||
func GetModelByName(name string) (interface{}, error) {
|
||||
return defaultRegistry.GetModel(name)
|
||||
registriesMutex.RLock()
|
||||
defer registriesMutex.RUnlock()
|
||||
|
||||
for _, registry := range registries {
|
||||
if model, err := registry.GetModel(name); err == nil {
|
||||
return model, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("model %s not found in any registry", name)
|
||||
}
|
||||
|
||||
// IterateModels iterates over all models in the default global registry
|
||||
@@ -122,14 +148,26 @@ func IterateModels(fn func(name string, model interface{})) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetModels returns a list of all models in the default global registry
|
||||
// GetModels returns a list of all models from all registries
|
||||
// Models are collected in registry order, with duplicates included
|
||||
func GetModels() []interface{} {
|
||||
defaultRegistry.mutex.RLock()
|
||||
defer defaultRegistry.mutex.RUnlock()
|
||||
registriesMutex.RLock()
|
||||
defer registriesMutex.RUnlock()
|
||||
|
||||
models := make([]interface{}, 0, len(defaultRegistry.models))
|
||||
for _, model := range defaultRegistry.models {
|
||||
models = append(models, model)
|
||||
var models []interface{}
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, registry := range registries {
|
||||
registry.mutex.RLock()
|
||||
for name, model := range registry.models {
|
||||
// Only add the first occurrence of each model name
|
||||
if !seen[name] {
|
||||
models = append(models, model)
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
registry.mutex.RUnlock()
|
||||
}
|
||||
|
||||
return models
|
||||
}
|
||||
|
||||
@@ -236,3 +236,90 @@ func ExtractColumnFromBunTag(tag string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsColumnWritable checks if a column can be written to in the database
|
||||
// For bun: returns false if the field has "scanonly" tag
|
||||
// For gorm: returns false if the field has "<-:false" or "->" (read-only) tag
|
||||
func IsColumnWritable(model any, columnName string) bool {
|
||||
modelType := reflect.TypeOf(model)
|
||||
|
||||
// Unwrap pointers to get to the base struct type
|
||||
for modelType != nil && modelType.Kind() == reflect.Pointer {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
|
||||
// Validate that we have a struct type
|
||||
if modelType == nil || modelType.Kind() != reflect.Struct {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := 0; i < modelType.NumField(); i++ {
|
||||
field := modelType.Field(i)
|
||||
|
||||
// Check if this field matches the column name
|
||||
fieldColumnName := getColumnNameFromField(field)
|
||||
if fieldColumnName != columnName {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check bun tag for scanonly
|
||||
bunTag := field.Tag.Get("bun")
|
||||
if bunTag != "" {
|
||||
if isBunFieldScanOnly(bunTag) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check gorm tag for write restrictions
|
||||
gormTag := field.Tag.Get("gorm")
|
||||
if gormTag != "" {
|
||||
if isGormFieldReadOnly(gormTag) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Column is writable
|
||||
return true
|
||||
}
|
||||
|
||||
// Column not found in model, allow it (might be a dynamic column)
|
||||
return true
|
||||
}
|
||||
|
||||
// isBunFieldScanOnly checks if a bun tag indicates the field is scan-only
|
||||
// Example: "column_name,scanonly" -> true
|
||||
func isBunFieldScanOnly(tag string) bool {
|
||||
parts := strings.Split(tag, ",")
|
||||
for _, part := range parts {
|
||||
if strings.TrimSpace(part) == "scanonly" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isGormFieldReadOnly checks if a gorm tag indicates the field is read-only
|
||||
// Examples:
|
||||
// - "<-:false" -> true (no writes allowed)
|
||||
// - "->" -> true (read-only, common pattern)
|
||||
// - "column:name;->" -> true
|
||||
// - "<-:create" -> false (writes allowed on create)
|
||||
func isGormFieldReadOnly(tag string) bool {
|
||||
parts := strings.Split(tag, ";")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
|
||||
// Check for read-only marker
|
||||
if part == "->" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for write restrictions
|
||||
if value, found := strings.CutPrefix(part, "<-:"); found {
|
||||
if value == "false" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1904,7 +1904,8 @@ func filterExtendedOptions(validator *common.ColumnValidator, options ExtendedRe
|
||||
}
|
||||
|
||||
// shouldUseNestedProcessor determines if we should use nested CUD processing
|
||||
// It checks if the data contains nested relations or a _request field
|
||||
// It recursively checks if the data contains deeply nested relations or _request fields
|
||||
// Simple one-level relations without further nesting don't require the nested processor
|
||||
func (h *Handler) shouldUseNestedProcessor(data map[string]interface{}, model interface{}) bool {
|
||||
return common.ShouldUseNestedProcessor(data, model, h)
|
||||
}
|
||||
@@ -1966,12 +1967,40 @@ func (h *Handler) getRelationshipInfo(modelType reflect.Type, relationName strin
|
||||
// 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
|
||||
|
||||
@@ -132,7 +132,7 @@ func TestShouldUseNestedProcessor(t *testing.T) {
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Data with nested posts",
|
||||
name: "Data with simple nested posts (no further nesting)",
|
||||
data: map[string]interface{}{
|
||||
"name": "John",
|
||||
"posts": []map[string]interface{}{
|
||||
@@ -140,7 +140,23 @@ func TestShouldUseNestedProcessor(t *testing.T) {
|
||||
},
|
||||
},
|
||||
model: TestUser{},
|
||||
expected: true,
|
||||
expected: false, // Simple one-level nesting doesn't require nested processor
|
||||
},
|
||||
{
|
||||
name: "Data with deeply nested relations",
|
||||
data: map[string]interface{}{
|
||||
"name": "John",
|
||||
"posts": []map[string]interface{}{
|
||||
{
|
||||
"title": "Post 1",
|
||||
"comments": []map[string]interface{}{
|
||||
{"content": "Comment 1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
model: TestUser{},
|
||||
expected: true, // Multi-level nesting requires nested processor
|
||||
},
|
||||
{
|
||||
name: "Data without nested relations",
|
||||
@@ -159,6 +175,20 @@ func TestShouldUseNestedProcessor(t *testing.T) {
|
||||
model: TestUser{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Nested data with _request field",
|
||||
data: map[string]interface{}{
|
||||
"name": "John",
|
||||
"posts": []map[string]interface{}{
|
||||
{
|
||||
"_request": "insert",
|
||||
"title": "Post 1",
|
||||
},
|
||||
},
|
||||
},
|
||||
model: TestUser{},
|
||||
expected: true, // _request at nested level requires nested processor
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
Reference in New Issue
Block a user