@@ -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+
1121var 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.
1526func 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
76204func attrFromTag (tag string ) string {
0 commit comments