Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions cmd/vef-cli/cmd/modelschema/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ func extractEmbedPrefixFromTag(tag string) string {
func extractColumnNameFromTag(tag, fieldName string) string {
bunTag := extractStructTag(tag, "bun")
if bunTag == "" {
return lo.SnakeCase(fieldName)
return underscore(fieldName)
}

if bunTag == "-" {
Expand All @@ -453,7 +453,7 @@ func extractColumnNameFromTag(tag, fieldName string) string {
return name
}

return lo.SnakeCase(fieldName)
return underscore(fieldName)
}

// bunColumnOption returns the value of an explicit "column:" option, which bun
Expand All @@ -470,6 +470,39 @@ func bunColumnOption(bunTag string) (string, bool) {
return "", false
}

// underscore converts a Go field name to a column name using the SAME algorithm bun
// uses at runtime (internal.Underscore), so generated column names match the columns bun
// actually reads and writes. Unlike lo.SnakeCase it does not insert an underscore before a
// digit (e.g. Addr2Line -> addr2_line, not addr_2_line); an uppercase byte is
// underscore-separated only when an adjacent byte is lowercase.
func underscore(s string) string {
isUpper := func(c byte) bool { return c >= 'A' && c <= 'Z' }
isLower := func(c byte) bool { return c >= 'a' && c <= 'z' }
toLower := func(c byte) byte {
if isUpper(c) {
return c + ('a' - 'A')
}

return c
}

r := make([]byte, 0, len(s)+5)
for i := 0; i < len(s); i++ {
c := s[i]
if isUpper(c) {
if i > 0 && i+1 < len(s) && (isLower(s[i-1]) || isLower(s[i+1])) {
r = append(r, '_', toLower(c))
} else {
r = append(r, toLower(c))
}
} else {
r = append(r, c)
}
}

return string(r)
}

// isRelationFieldFromTag checks if a bun tag declares a model relationship (rel:has-one, rel:has-many, rel:belongs-to, rel:many-to-many).
func isRelationFieldFromTag(tag string) bool {
bunTag := extractStructTag(tag, "bun")
Expand Down
6 changes: 6 additions & 0 deletions cmd/vef-cli/cmd/modelschema/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ func TestExtractColumnNameFromTag(t *testing.T) {
{"ColumnOptionOverridesFirstSegment", `bun:"first_seg,column:explicit_col"`, "Foo", "explicit_col"},
// Bun keeps a bare option-like first segment as the column name (it only warns).
{"BareOptionNameKept", `bun:"notnull"`, "Foo", "notnull"},
// Derived names use bun's Underscore, which (unlike lo.SnakeCase) does not insert
// an underscore before a digit, so generated columns match bun's runtime columns.
{"DigitDerivedFromTypeOption", `bun:"type:jsonb"`, "Name2", "name2"},
{"DigitDerivedMidName", `bun:",nullzero"`, "Addr2Line", "addr2_line"},
{"DigitDerivedNoTag", "", "Line2", "line2"},
{"AcronymDerived", `bun:",notnull"`, "OrganizationID", "organization_id"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
Loading