From e70e4fc5659c73f641ae293b33ecc4372cc11c28 Mon Sep 17 00:00:00 2001 From: Evander Date: Tue, 30 Jun 2026 15:23:31 +0800 Subject: [PATCH] fix(vef-cli): use bun's Underscore for derived schema column names extractColumnNameFromTag fell back to lo.SnakeCase when a field had no explicit column name (e.g. `bun:"type:jsonb"` or `bun:",nullzero"`). lo.SnakeCase inserts an underscore before digits (Name2 -> name_2), but bun derives the column with internal.Underscore, which does not (Name2 -> name2). The generated accessor then referenced a column that does not exist for any field whose name contains a digit. Replace the fallback with a local copy of bun's Underscore so generated column names match the columns bun reads and writes at runtime. --- cmd/vef-cli/cmd/modelschema/generator.go | 37 ++++++++++++++++++- cmd/vef-cli/cmd/modelschema/generator_test.go | 6 +++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/cmd/vef-cli/cmd/modelschema/generator.go b/cmd/vef-cli/cmd/modelschema/generator.go index 77d0932..d336c29 100644 --- a/cmd/vef-cli/cmd/modelschema/generator.go +++ b/cmd/vef-cli/cmd/modelschema/generator.go @@ -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 == "-" { @@ -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 @@ -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") diff --git a/cmd/vef-cli/cmd/modelschema/generator_test.go b/cmd/vef-cli/cmd/modelschema/generator_test.go index c2c2d94..6ab48a6 100644 --- a/cmd/vef-cli/cmd/modelschema/generator_test.go +++ b/cmd/vef-cli/cmd/modelschema/generator_test.go @@ -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) {