Skip to content

Commit 86c394f

Browse files
authored
Merge pull request #14 from pay-theory/fix/relaxed-marshalling
Fix: Implement relaxed marshalling and merge legacy snake_case support
2 parents 9f420bf + e5795c5 commit 86c394f

File tree

9 files changed

+597
-81
lines changed

9 files changed

+597
-81
lines changed

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.0.34] - 2025-10-29
11+
12+
### Fixed
13+
- **[CRITICAL]** Custom converters now properly invoked during `Update()` operations
14+
- Security validation was rejecting custom struct types before converter check
15+
- Fixed by checking for custom converters BEFORE security validation
16+
- Custom types with registered converters now bypass security validation (converters handle their own validation)
17+
- Removed silent NULL fallbacks - validation/conversion failures now panic with clear error messages
18+
- Field name validation in `Update()` - unknown field names now return clear error messages instead of silently skipping
19+
1020
## [1.0.33] - 2025-10-28
1121

1222
### Added
23+
- Support for legacy snake_case naming convention alongside default camelCase:
24+
- New `naming:snake_case` struct tag to opt-in to snake_case attribute names
25+
- Automatic conversion of Go field names to snake_case (e.g., `FirstName``first_name`)
26+
- Smart acronym handling in snake_case conversion (e.g., `UserID``user_id`, `URLValue``url_value`)
27+
- Per-model naming convention detection and validation
28+
- Both naming conventions can coexist in the same application
29+
- Integration tests demonstrating mixed convention usage
1330
- `OrCondition` method to UpdateBuilder for OR logic in conditional expressions:
1431
- Enables complex business rules like rate limiting with privilege checks
1532
- Supports mixing AND/OR conditions with left-to-right evaluation

dynamorm.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2307,12 +2307,15 @@ func (q *query) updateItem(metadata *model.Metadata, fields []string) error {
23072307
}
23082308

23092309
// Build update expression with custom converter support
2310+
fmt.Printf("🔍 DYNAMORM UPDATE: q.db=%T, q.db.converter=%p\n", q.db, q.db.converter)
23102311
builder := expr.NewBuilderWithConverter(q.db.converter)
2312+
fmt.Printf("🔍 DYNAMORM UPDATE: Builder created with converter=%p\n", builder)
23112313

23122314
modelValue := reflect.ValueOf(q.model)
23132315
if modelValue.Kind() == reflect.Ptr {
23142316
modelValue = modelValue.Elem()
23152317
}
2318+
fmt.Printf("🔍 DYNAMORM UPDATE: Model type=%v\n", modelValue.Type())
23162319

23172320
// Determine which fields to update
23182321
fieldsToUpdate := fields
@@ -2331,13 +2334,18 @@ func (q *query) updateItem(metadata *model.Metadata, fields []string) error {
23312334
}
23322335

23332336
// Build SET expressions
2337+
fmt.Printf("🔍 DYNAMORM UPDATE: Fields to update: %v\n", fieldsToUpdate)
23342338
for _, fieldName := range fieldsToUpdate {
23352339
fieldMeta, exists := lookupField(metadata, fieldName)
23362340
if !exists {
2337-
continue
2341+
// Don't silently skip - return error for unknown fields
2342+
fmt.Printf("❌ DYNAMORM UPDATE: Field '%s' not found in metadata\n", fieldName)
2343+
return fmt.Errorf("field '%s' not found in model metadata (use Go field name or DB attribute name)", fieldName)
23382344
}
23392345

23402346
fieldValue := modelValue.FieldByIndex(fieldMeta.IndexPath)
2347+
fmt.Printf("🔍 DYNAMORM UPDATE: Field '%s' -> DB name '%s', value type=%T\n",
2348+
fieldName, fieldMeta.DBName, fieldValue.Interface())
23412349

23422350
// Handle special fields
23432351
if fieldMeta.IsUpdatedAt {
@@ -2349,6 +2357,7 @@ func (q *query) updateItem(metadata *model.Metadata, fields []string) error {
23492357
} else {
23502358
// Regular field update
23512359
value := fieldValue.Interface()
2360+
fmt.Printf("🔍 DYNAMORM UPDATE: Calling AddUpdateSet('%s', %T)\n", fieldMeta.DBName, value)
23522361
builder.AddUpdateSet(fieldMeta.DBName, value)
23532362
}
23542363
}

internal/expr/builder.go

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -563,41 +563,39 @@ func (b *Builder) isReservedWord(word string) bool {
563563

564564
// addValueSecure adds an attribute value with security validation
565565
func (b *Builder) addValueSecure(value any) string {
566-
// Security validation
567-
if err := validation.ValidateValue(value); err != nil {
568-
// SECURITY: Return safe placeholder without logging value details
569-
b.valueCounter++
570-
placeholder := fmt.Sprintf(":invalid%d", b.valueCounter)
571-
b.values[placeholder] = &types.AttributeValueMemberNULL{Value: true}
572-
return placeholder
573-
}
574-
575566
b.valueCounter++
576567
placeholder := fmt.Sprintf(":v%d", b.valueCounter)
577568

578569
// Convert value to AttributeValue securely
579570
var av types.AttributeValue
580571
var err error
581572

582-
// First, check if we have a custom converter registered for this type
573+
// CRITICAL: Check for custom converter FIRST, before security validation
574+
// Custom converters handle their own validation and marshaling
583575
if b.converter != nil && value != nil {
584576
valueType := reflect.TypeOf(value)
585577
if b.converter.HasCustomConverter(valueType) {
586-
// Use custom converter
578+
// Use custom converter - bypass security validation for custom types
587579
av, err = b.converter.ToAttributeValue(value)
588-
if err == nil {
589-
b.values[placeholder] = av
590-
return placeholder
580+
if err != nil {
581+
// Custom converter failed - this is a real error, return NULL
582+
av = &types.AttributeValueMemberNULL{Value: true}
591583
}
592-
// If custom converter fails, fall through to default conversion
584+
b.values[placeholder] = av
585+
return placeholder
593586
}
594587
}
595588

596-
// Fall back to default conversion
589+
// Security validation for non-custom types
590+
if err := validation.ValidateValue(value); err != nil {
591+
// Don't silently convert to NULL - this hides bugs
592+
panic(fmt.Sprintf("DynamORM: Invalid value type %T failed security validation: %v", value, err))
593+
}
594+
595+
// Convert using default conversion
597596
av, err = ConvertToAttributeValueSecure(value)
598597
if err != nil {
599-
// SECURITY: Store as NULL for safety without logging details
600-
av = &types.AttributeValueMemberNULL{Value: true}
598+
panic(fmt.Sprintf("DynamORM: Failed to convert value type %T: %v", value, err))
601599
}
602600

603601
b.values[placeholder] = av

pkg/model/registry.go

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -91,16 +91,17 @@ func (r *Registry) GetMetadataByTable(tableName string) (*Metadata, error) {
9191

9292
// Metadata holds all metadata for a model
9393
type Metadata struct {
94-
Type reflect.Type
95-
TableName string
96-
PrimaryKey *KeySchema
97-
Indexes []IndexSchema
98-
Fields map[string]*FieldMetadata
99-
FieldsByDBName map[string]*FieldMetadata
100-
VersionField *FieldMetadata
101-
TTLField *FieldMetadata
102-
CreatedAtField *FieldMetadata
103-
UpdatedAtField *FieldMetadata
94+
Type reflect.Type
95+
TableName string
96+
NamingConvention naming.Convention
97+
PrimaryKey *KeySchema
98+
Indexes []IndexSchema
99+
Fields map[string]*FieldMetadata
100+
FieldsByDBName map[string]*FieldMetadata
101+
VersionField *FieldMetadata
102+
TTLField *FieldMetadata
103+
CreatedAtField *FieldMetadata
104+
UpdatedAtField *FieldMetadata
104105
}
105106

106107
// KeySchema represents a primary key or index key schema
@@ -188,12 +189,16 @@ func parseMetadata(modelType reflect.Type) (*Metadata, error) {
188189
tableName = getTableName(modelType)
189190
}
190191

192+
// Detect naming convention from struct tags
193+
convention := detectNamingConvention(modelType)
194+
191195
metadata := &Metadata{
192-
Type: modelType,
193-
TableName: tableName,
194-
Fields: make(map[string]*FieldMetadata),
195-
FieldsByDBName: make(map[string]*FieldMetadata),
196-
Indexes: make([]IndexSchema, 0),
196+
Type: modelType,
197+
TableName: tableName,
198+
NamingConvention: convention,
199+
Fields: make(map[string]*FieldMetadata),
200+
FieldsByDBName: make(map[string]*FieldMetadata),
201+
Indexes: make([]IndexSchema, 0),
197202
}
198203

199204
indexMap := make(map[string]*IndexSchema)
@@ -244,7 +249,7 @@ func parseFields(modelType reflect.Type, metadata *Metadata, indexMap map[string
244249
}
245250

246251
// Parse regular field
247-
fieldMeta, err := parseFieldMetadata(field, currentPath)
252+
fieldMeta, err := parseFieldMetadata(field, currentPath, metadata.NamingConvention)
248253
if err != nil {
249254
return fmt.Errorf("field validation failed: %w", err)
250255
}
@@ -338,11 +343,11 @@ func parseFields(modelType reflect.Type, metadata *Metadata, indexMap map[string
338343
}
339344

340345
// parseFieldMetadata parses metadata for a single field
341-
func parseFieldMetadata(field reflect.StructField, indexPath []int) (*FieldMetadata, error) {
346+
func parseFieldMetadata(field reflect.StructField, indexPath []int, convention naming.Convention) (*FieldMetadata, error) {
342347
meta := &FieldMetadata{
343348
Name: field.Name,
344349
Type: field.Type,
345-
DBName: naming.DefaultAttrName(field.Name),
350+
DBName: naming.ConvertAttrName(field.Name, convention),
346351
Index: indexPath[len(indexPath)-1], // Keep for backward compatibility
347352
IndexPath: indexPath,
348353
Tags: make(map[string]string),
@@ -443,7 +448,7 @@ func parseFieldMetadata(field reflect.StructField, indexPath []int) (*FieldMetad
443448
return nil, err
444449
}
445450

446-
if err := naming.ValidateAttrName(meta.DBName); err != nil {
451+
if err := naming.ValidateAttrName(meta.DBName, convention); err != nil {
447452
return nil, fmt.Errorf("%w: %v", errors.ErrInvalidTag, err)
448453
}
449454

@@ -593,6 +598,38 @@ func splitTags(tag string) []string {
593598
return parts
594599
}
595600

601+
// detectNamingConvention scans struct fields for a naming convention tag.
602+
// It looks for a field (typically blank identifier _) with tag `dynamorm:"naming:snake_case"`.
603+
// Returns CamelCase (default) if no naming tag is found.
604+
func detectNamingConvention(modelType reflect.Type) naming.Convention {
605+
for i := 0; i < modelType.NumField(); i++ {
606+
field := modelType.Field(i)
607+
tag := field.Tag.Get("dynamorm")
608+
609+
if tag == "" {
610+
continue
611+
}
612+
613+
// Look for naming:snake_case or naming:camel_case
614+
parts := strings.Split(tag, ",")
615+
for _, part := range parts {
616+
part = strings.TrimSpace(part)
617+
if strings.HasPrefix(part, "naming:") {
618+
convention := strings.TrimPrefix(part, "naming:")
619+
switch convention {
620+
case "snake_case":
621+
return naming.SnakeCase
622+
case "camel_case", "camelCase":
623+
return naming.CamelCase
624+
}
625+
}
626+
}
627+
}
628+
629+
// Default to CamelCase
630+
return naming.CamelCase
631+
}
632+
596633
// isStandaloneTag checks if the string starts with a standalone tag (not an index modifier)
597634
func isStandaloneTag(s string) bool {
598635
// Check for simple tags

0 commit comments

Comments
 (0)