Skip to content

Commit e5795c5

Browse files
committed
Merge remote-tracking branch 'origin/feat/legacy-snake-case' into fix/relaxed-marshalling
2 parents d9a9691 + af4e163 commit e5795c5

File tree

6 files changed

+544
-41
lines changed

6 files changed

+544
-41
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
## [1.0.33] - 2025-10-28
2121

2222
### 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
2330
- `OrCondition` method to UpdateBuilder for OR logic in conditional expressions:
2431
- Enables complex business rules like rate limiting with privilege checks
2532
- Supports mixing AND/OR conditions with left-to-right evaluation

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

pkg/naming/naming.go

Lines changed: 138 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,28 @@ import (
88
"unicode"
99
)
1010

11+
// Convention represents the naming convention for DynamoDB attribute names.
12+
type Convention int
13+
14+
const (
15+
// CamelCase convention: "firstName", "createdAt", with special handling for "PK" and "SK"
16+
CamelCase Convention = 0
17+
// SnakeCase convention: "first_name", "created_at"
18+
SnakeCase Convention = 1
19+
)
20+
1121
var camelCasePattern = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`)
22+
var snakeCasePattern = regexp.MustCompile(`^[a-z][a-z0-9]*(_[a-z0-9]+)*$`)
1223

13-
// ResolveAttrName determines the DynamoDB attribute name for a field.
24+
// ResolveAttrName determines the DynamoDB attribute name for a field using CamelCase convention.
1425
// It returns the attribute name and a bool indicating whether the field should be skipped.
1526
func ResolveAttrName(field reflect.StructField) (string, bool) {
27+
return ResolveAttrNameWithConvention(field, CamelCase)
28+
}
29+
30+
// ResolveAttrNameWithConvention determines the DynamoDB attribute name for a field using the specified convention.
31+
// It returns the attribute name and a bool indicating whether the field should be skipped.
32+
func ResolveAttrNameWithConvention(field reflect.StructField, convention Convention) (string, bool) {
1633
tag := field.Tag.Get("dynamorm")
1734
if tag == "-" {
1835
return "", true
@@ -22,7 +39,7 @@ func ResolveAttrName(field reflect.StructField) (string, bool) {
2239
return attr, false
2340
}
2441

25-
return DefaultAttrName(field.Name), false
42+
return ConvertAttrName(field.Name, convention), false
2643
}
2744

2845
// DefaultAttrName converts a Go struct field name to the preferred camelCase DynamoDB attribute name.
@@ -57,20 +74,131 @@ func DefaultAttrName(name string) string {
5774
return prefix + string(runes[boundary:])
5875
}
5976

60-
// ValidateAttrName enforces camelCase (with PK/SK exceptions) for DynamoDB attribute names.
61-
func ValidateAttrName(name string) error {
77+
// ToSnakeCase converts a Go struct field name to snake_case DynamoDB attribute name.
78+
// It uses smart acronym handling: "URLValue" → "url_value", "ID" → "id", "UserID" → "user_id".
79+
func ToSnakeCase(name string) string {
6280
if name == "" {
63-
return fmt.Errorf("attribute name cannot be empty")
81+
return ""
6482
}
6583

66-
if name == "PK" || name == "SK" {
67-
return nil
84+
runes := []rune(name)
85+
if len(runes) == 1 {
86+
return strings.ToLower(name)
87+
}
88+
89+
var result []rune
90+
var currentWord []rune
91+
92+
for i := 0; i < len(runes); i++ {
93+
ch := runes[i]
94+
95+
// If we hit an uppercase letter, we might be starting a new word
96+
if unicode.IsUpper(ch) {
97+
// Look ahead to determine if this is part of an acronym sequence
98+
isAcronym := false
99+
if i+1 < len(runes) && unicode.IsUpper(runes[i+1]) {
100+
// Next char is also uppercase, so this is part of an acronym
101+
isAcronym = true
102+
}
103+
104+
// Check if previous character was a digit
105+
previousWasDigit := i > 0 && unicode.IsDigit(runes[i-1])
106+
107+
// If we have a current word, flush it before starting new one
108+
// But don't add underscore if previous char was a digit
109+
if len(currentWord) > 0 {
110+
if len(result) > 0 && !previousWasDigit {
111+
result = append(result, '_')
112+
}
113+
result = append(result, currentWord...)
114+
currentWord = nil
115+
}
116+
117+
// Start collecting the new word (or acronym)
118+
if isAcronym {
119+
// Collect the acronym sequence
120+
acronym := []rune{ch}
121+
j := i + 1
122+
for j < len(runes) && unicode.IsUpper(runes[j]) {
123+
// Check if next char after this is lowercase (end of acronym)
124+
if j+1 < len(runes) && !unicode.IsUpper(runes[j+1]) {
125+
// This uppercase char belongs to the next word
126+
// e.g., in "URLValue", 'V' starts "Value"
127+
break
128+
}
129+
acronym = append(acronym, runes[j])
130+
j++
131+
}
132+
133+
// Add the acronym as a word
134+
if len(result) > 0 {
135+
result = append(result, '_')
136+
}
137+
for _, r := range acronym {
138+
result = append(result, unicode.ToLower(r))
139+
}
140+
i = j - 1 // -1 because loop will increment
141+
} else {
142+
// Single uppercase letter starting a word
143+
currentWord = []rune{unicode.ToLower(ch)}
144+
}
145+
} else {
146+
// Lowercase or digit, add to current word
147+
currentWord = append(currentWord, ch)
148+
}
149+
}
150+
151+
// Flush any remaining word
152+
if len(currentWord) > 0 {
153+
// Check if last character of result is a digit
154+
shouldAddUnderscore := len(result) > 0 && !unicode.IsDigit(result[len(result)-1])
155+
if shouldAddUnderscore {
156+
result = append(result, '_')
157+
}
158+
result = append(result, currentWord...)
159+
}
160+
161+
return string(result)
162+
}
163+
164+
// ConvertAttrName converts a field name to the appropriate naming convention.
165+
func ConvertAttrName(name string, convention Convention) string {
166+
switch convention {
167+
case SnakeCase:
168+
return ToSnakeCase(name)
169+
case CamelCase:
170+
fallthrough
171+
default:
172+
return DefaultAttrName(name)
173+
}
174+
}
175+
176+
// ValidateAttrName enforces the naming convention for DynamoDB attribute names.
177+
// For CamelCase: allows "PK" and "SK" as exceptions, otherwise enforces camelCase pattern.
178+
// For SnakeCase: enforces snake_case pattern (no special exceptions).
179+
func ValidateAttrName(name string, convention Convention) error {
180+
if name == "" {
181+
return fmt.Errorf("attribute name cannot be empty")
68182
}
69183

70-
if !camelCasePattern.MatchString(name) {
71-
return fmt.Errorf("attribute name must be camelCase (got %q)", name)
184+
switch convention {
185+
case SnakeCase:
186+
if !snakeCasePattern.MatchString(name) {
187+
return fmt.Errorf("attribute name must be snake_case (got %q)", name)
188+
}
189+
return nil
190+
case CamelCase:
191+
fallthrough
192+
default:
193+
// CamelCase validation with PK/SK exceptions
194+
if name == "PK" || name == "SK" {
195+
return nil
196+
}
197+
if !camelCasePattern.MatchString(name) {
198+
return fmt.Errorf("attribute name must be camelCase (got %q)", name)
199+
}
200+
return nil
72201
}
73-
return nil
74202
}
75203

76204
func attrFromTag(tag string) string {

0 commit comments

Comments
 (0)