feat: add --type-map to override SQL-to-Go types in bun and gorm writers

Adds WriterOptions.TypeMappings and a repeatable --type-map sqltype=gotype
flag. Defaults are unchanged when no mapping is given. Closes #36 (bun/gorm).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Hermes Agent
2026-10-03 10:28:58 +02:00
co-authored by Claude Sonnet 5.5
parent b38f53c603
commit 948419ffd3
12 changed files with 230 additions and 0 deletions
+25
View File
@@ -12,6 +12,7 @@ import (
// TypeMapper handles type conversions between SQL and Go types for Bun
type TypeMapper struct {
sqlTypesAlias string
typeMappings map[string]string
typeStyle string // writers.NullableTypeSqlTypes | writers.NullableTypeStdlib | writers.NullableTypeBaselib
arrayNullable string // writers.NullableArraysSlice | writers.NullableArraysPointerSlice
}
@@ -37,6 +38,10 @@ func NewTypeMapper(typeStyle, arrayNullable string) *TypeMapper {
// SQLTypeToGoType converts a SQL type to its Go equivalent.
func (tm *TypeMapper) SQLTypeToGoType(sqlType string, notNull bool) string {
if goType, ok := tm.overrideGoType(sqlType, notNull); ok {
return goType
}
// Array columns always use a native Go slice, regardless of typeStyle.
if pgsql.IsArrayType(sqlType) {
goType := tm.arrayGoType(tm.extractBaseType(sqlType))
@@ -68,6 +73,26 @@ func (tm *TypeMapper) SQLTypeToGoType(sqlType string, notNull bool) string {
return tm.bunGoType(baseType)
}
// SetTypeMappings installs user-configured SQL-to-Go type overrides.
func (tm *TypeMapper) SetTypeMappings(mappings map[string]string) {
tm.typeMappings = mappings
}
// overrideGoType applies a configured override, if any, for the column type.
func (tm *TypeMapper) overrideGoType(sqlType string, notNull bool) (string, bool) {
if len(tm.typeMappings) == 0 {
return "", false
}
goType, ok := writers.LookupTypeMapping(tm.typeMappings, tm.extractBaseType(sqlType))
if !ok {
return "", false
}
if pgsql.IsArrayType(sqlType) {
return "[]" + goType, true
}
return writers.ApplyTypeMapping(goType, notNull), true
}
// extractBaseType extracts the base type from a SQL type string
func (tm *TypeMapper) extractBaseType(sqlType string) string {
return pgsql.CanonicalizeBaseType(pgsql.ExtractBaseTypeLower(sqlType))
@@ -0,0 +1,25 @@
package bun
import "testing"
func TestTypeMapper_CustomTypeMappings(t *testing.T) {
mapper := NewTypeMapper("", "")
mapper.SetTypeMappings(map[string]string{"uuid": "uuid.UUID", "numeric": "decimal.Decimal"})
tests := []struct {
sqlType string
notNull bool
want string
}{
{"uuid", true, "uuid.UUID"},
{"uuid", false, "*uuid.UUID"},
{"numeric(10,2)", true, "decimal.Decimal"},
{"UUID[]", false, "[]uuid.UUID"},
{"bigint", true, "int64"}, // unmapped types keep defaults
}
for _, tt := range tests {
if got := mapper.SQLTypeToGoType(tt.sqlType, tt.notNull); got != tt.want {
t.Errorf("SQLTypeToGoType(%q, %v) = %q, want %q", tt.sqlType, tt.notNull, got, tt.want)
}
}
}
+2
View File
@@ -28,6 +28,8 @@ func NewWriter(options *writers.WriterOptions) *Writer {
config: LoadMethodConfigFromMetadata(options.Metadata),
}
w.typeMapper.SetTypeMappings(options.TypeMappings)
// Initialize templates
tmpl, err := NewTemplates()
if err != nil {
+25
View File
@@ -12,6 +12,7 @@ import (
// TypeMapper handles type conversions between SQL and Go types
type TypeMapper struct {
sqlTypesAlias string
typeMappings map[string]string
typeStyle string // writers.NullableTypeSqlTypes | writers.NullableTypeStdlib | writers.NullableTypeBaselib
}
@@ -30,6 +31,10 @@ func NewTypeMapper(typeStyle string) *TypeMapper {
// SQLTypeToGoType converts a SQL type to its Go equivalent.
func (tm *TypeMapper) SQLTypeToGoType(sqlType string, notNull bool) string {
if goType, ok := tm.overrideGoType(sqlType, notNull); ok {
return goType
}
// Array types are handled separately for both styles.
if pgsql.IsArrayType(sqlType) {
return tm.arrayGoType(tm.extractBaseType(sqlType))
@@ -57,6 +62,26 @@ func (tm *TypeMapper) SQLTypeToGoType(sqlType string, notNull bool) string {
return tm.nullableGoType(baseType)
}
// SetTypeMappings installs user-configured SQL-to-Go type overrides.
func (tm *TypeMapper) SetTypeMappings(mappings map[string]string) {
tm.typeMappings = mappings
}
// overrideGoType applies a configured override, if any, for the column type.
func (tm *TypeMapper) overrideGoType(sqlType string, notNull bool) (string, bool) {
if len(tm.typeMappings) == 0 {
return "", false
}
goType, ok := writers.LookupTypeMapping(tm.typeMappings, tm.extractBaseType(sqlType))
if !ok {
return "", false
}
if pgsql.IsArrayType(sqlType) {
return "[]" + goType, true
}
return writers.ApplyTypeMapping(goType, notNull), true
}
// extractBaseType extracts the base type from a SQL type string
// Examples: varchar(100) → varchar, numeric(10,2) → numeric
func (tm *TypeMapper) extractBaseType(sqlType string) string {
@@ -0,0 +1,25 @@
package gorm
import "testing"
func TestTypeMapper_CustomTypeMappings(t *testing.T) {
mapper := NewTypeMapper("")
mapper.SetTypeMappings(map[string]string{"uuid": "uuid.UUID", "numeric": "decimal.Decimal"})
tests := []struct {
sqlType string
notNull bool
want string
}{
{"uuid", true, "uuid.UUID"},
{"uuid", false, "*uuid.UUID"},
{"numeric(10,2)", true, "decimal.Decimal"},
{"UUID[]", false, "[]uuid.UUID"},
{"bigint", true, "int64"}, // unmapped types keep defaults
}
for _, tt := range tests {
if got := mapper.SQLTypeToGoType(tt.sqlType, tt.notNull); got != tt.want {
t.Errorf("SQLTypeToGoType(%q, %v) = %q, want %q", tt.sqlType, tt.notNull, got, tt.want)
}
}
}
+2
View File
@@ -28,6 +28,8 @@ func NewWriter(options *writers.WriterOptions) *Writer {
config: LoadMethodConfigFromMetadata(options.Metadata),
}
w.typeMapper.SetTypeMappings(options.TypeMappings)
// Initialize templates
tmpl, err := NewTemplates()
if err != nil {
+46
View File
@@ -0,0 +1,46 @@
package writers
import (
"fmt"
"strings"
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
)
// ParseTypeMappings parses "sqltype=gotype" entries (as given to --type-map)
// into a map keyed by the canonical lower-case SQL base type, so that
// "VARCHAR", "character varying" and "varchar(50)" all address one entry.
// It returns nil for empty input.
func ParseTypeMappings(entries []string) (map[string]string, error) {
if len(entries) == 0 {
return nil, nil
}
out := make(map[string]string, len(entries))
for _, entry := range entries {
sqlType, goType, ok := strings.Cut(entry, "=")
sqlType, goType = strings.TrimSpace(sqlType), strings.TrimSpace(goType)
if !ok || sqlType == "" || goType == "" {
return nil, fmt.Errorf("invalid type mapping %q: expected sqltype=gotype", entry)
}
out[pgsql.CanonicalizeBaseType(pgsql.ExtractBaseTypeLower(sqlType))] = goType
}
return out, nil
}
// LookupTypeMapping returns the user-configured override for baseType, which
// the caller must already have canonicalized.
func LookupTypeMapping(mappings map[string]string, baseType string) (string, bool) {
goType, ok := mappings[baseType]
return goType, ok
}
// ApplyTypeMapping wraps an overridden Go type for nullability: NOT NULL uses
// the type verbatim; nullable columns get a pointer prefix unless the type is
// already a pointer, slice, map or interface.
func ApplyTypeMapping(goType string, notNull bool) string {
if notNull || strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") ||
strings.HasPrefix(goType, "map[") || goType == "any" || goType == "interface{}" {
return goType
}
return "*" + goType
}
+46
View File
@@ -0,0 +1,46 @@
package writers
import (
"reflect"
"testing"
)
func TestParseTypeMappings(t *testing.T) {
got, err := ParseTypeMappings([]string{"UUID=uuid.UUID", " varchar(50) = MyString ", "INT4=MyInt"})
if err != nil {
t.Fatal(err)
}
want := map[string]string{"uuid": "uuid.UUID", "varchar": "MyString", "integer": "MyInt"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
if m, err := ParseTypeMappings(nil); m != nil || err != nil {
t.Errorf("empty input: got %v, %v", m, err)
}
for _, bad := range []string{"uuid", "=string", "uuid="} {
if _, err := ParseTypeMappings([]string{bad}); err == nil {
t.Errorf("expected error for %q", bad)
}
}
}
func TestApplyTypeMapping(t *testing.T) {
tests := []struct {
goType string
notNull bool
want string
}{
{"uuid.UUID", true, "uuid.UUID"},
{"uuid.UUID", false, "*uuid.UUID"},
{"*uuid.UUID", false, "*uuid.UUID"},
{"[]byte", false, "[]byte"},
{"map[string]any", false, "map[string]any"},
{"any", false, "any"},
}
for _, tt := range tests {
if got := ApplyTypeMapping(tt.goType, tt.notNull); got != tt.want {
t.Errorf("ApplyTypeMapping(%q, %v) = %q, want %q", tt.goType, tt.notNull, got, tt.want)
}
}
}
+7
View File
@@ -83,6 +83,13 @@ type WriterOptions struct {
// SqlXxxArray wrapper types.
NullableArrays string
// TypeMappings overrides the SQL-to-Go type mapping of the code-generation
// writers (bun, gorm). Keys are SQL base types (aliases are canonicalized,
// see ParseTypeMappings), values are Go type expressions. Array columns
// use the override for their element type. Unmapped types keep the
// built-in defaults.
TypeMappings map[string]string
// Prisma7 enables Prisma 7-specific output for Prisma writers.
Prisma7 bool