feat(bun): generate native Go array slices for PostgreSQL array columns

Bun's pgdialect scans/appends native slices directly, so array columns
(text[], integer[], uuid[], ...) always generate as plain []string,
[]int32, etc. with an explicit "array" bun tag, regardless of --types
(sqltypes/stdlib/baselib). The SqlXxxArray wrapper types are no longer
used for Bun array columns (gorm is unaffected and keeps using them).

Adds --array-nullable pointer_slice to represent nullable array columns
as *[]T instead of []T, so callers can distinguish SQL NULL (nil) from
'{}' (pointer to an empty slice). Verified end-to-end against a live
PostgreSQL instance for NULL/{}/populated arrays in every --types mode.

Closes #13
This commit is contained in:
Hein
2026-07-21 12:41:38 +02:00
parent 2cecb4c11c
commit 5d9ff5df03
65 changed files with 9584 additions and 167 deletions
+58 -45
View File
@@ -556,7 +556,7 @@ func TestWriter_FieldNameCollision(t *testing.T) {
}
func TestTypeMapper_SQLTypeToGoType_Bun(t *testing.T) {
mapper := NewTypeMapper("")
mapper := NewTypeMapper("", "")
tests := []struct {
sqlType string
@@ -701,7 +701,7 @@ func TestWriter_StringPrimaryKeyHelpers_Bun(t *testing.T) {
}
func TestTypeMapper_BuildBunTag(t *testing.T) {
mapper := NewTypeMapper("")
mapper := NewTypeMapper("", "")
tests := []struct {
name string
@@ -827,65 +827,78 @@ func TestTypeMapper_BuildBunTag(t *testing.T) {
t.Errorf("BuildBunTag() = %q, missing %q", result, part)
}
}
// baselib mode must NOT add "array" — the Go type is already a
// real slice ([]string, []int32, ...), which bun's pgdialect
// scans natively without the explicit "array" tag option.
if strings.Contains(result, ",array,") || strings.HasSuffix(result, ",array,") {
t.Errorf("BuildBunTag() = %q, must not contain 'array' in baselib mode", result)
// Array columns always carry the "array" tag, telling bun's
// pgdialect to scan/append the native Go slice as a PostgreSQL array.
if strings.HasSuffix(tt.column.Type, "[]") && !strings.Contains(result, ",array,") {
t.Errorf("BuildBunTag() = %q, expected 'array' tag", result)
}
})
}
}
// TestTypeMapper_BuildBunTag_SqlTypesArrayUsesInternalTypeName verifies that
// array columns in sqltypes mode never produce a "[]"-suffixed "type:" tag.
// bun's pgdialect unconditionally overrides Field.Scan/Append with its own
// (slice-only) array handling whenever the tag's "type:" value ends in "[]",
// which clobbers the sql.Scanner/driver.Valuer implemented on the
// SqlXxxArray wrapper types and produces
// "bun: Scan(unsupported sqltypes.SqlXxxArray)" at query time.
func TestTypeMapper_BuildBunTag_SqlTypesArrayUsesInternalTypeName(t *testing.T) {
mapper := NewTypeMapper(writers.NullableTypeSqlTypes)
// TestTypeMapper_BuildBunTag_ArraysAreNativeInEveryMode verifies that array
// columns always use a plain "text[]"-style type and the native Go slice
// type plus an explicit "array" tag, regardless of NullableTypes style
// (sqltypes/stdlib/baselib). bun's pgdialect scans/appends native slices
// directly; the SqlXxxArray wrapper types are never used for array columns.
func TestTypeMapper_BuildBunTag_ArraysAreNativeInEveryMode(t *testing.T) {
for _, style := range []string{writers.NullableTypeSqlTypes, writers.NullableTypeStdlib, writers.NullableTypeBaselib} {
t.Run(style, func(t *testing.T) {
mapper := NewTypeMapper(style, "")
cases := []struct {
name string
column *models.Column
wantSubstr string
}{
{name: "text array", column: &models.Column{Name: "tags", Type: "text[]"}, wantSubstr: "type:_text,"},
{name: "varchar array", column: &models.Column{Name: "labels", Type: "varchar[]"}, wantSubstr: "type:_varchar,"},
{name: "integer array", column: &models.Column{Name: "scores", Type: "integer[]", NotNull: true}, wantSubstr: "type:_int4,"},
{name: "boolean array", column: &models.Column{Name: "flags", Type: "boolean[]"}, wantSubstr: "type:_bool,"},
{name: "uuid array", column: &models.Column{Name: "ids", Type: "uuid[]"}, wantSubstr: "type:_uuid,"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
result := mapper.BuildBunTag(tt.column, nil)
if !strings.Contains(result, tt.wantSubstr) {
t.Errorf("BuildBunTag() = %q, missing %q", result, tt.wantSubstr)
cases := []struct {
name string
column *models.Column
wantSubstr string
}{
{name: "text array", column: &models.Column{Name: "tags", Type: "text[]"}, wantSubstr: "type:text[],array,"},
{name: "varchar array", column: &models.Column{Name: "labels", Type: "varchar[]"}, wantSubstr: "type:varchar[],array,"},
{name: "integer array", column: &models.Column{Name: "scores", Type: "integer[]", NotNull: true}, wantSubstr: "type:integer[],array,"},
{name: "boolean array", column: &models.Column{Name: "flags", Type: "boolean[]"}, wantSubstr: "type:boolean[],array,"},
{name: "uuid array", column: &models.Column{Name: "ids", Type: "uuid[]"}, wantSubstr: "type:uuid[],array,"},
}
if strings.Contains(result, "[]") {
t.Errorf("BuildBunTag() = %q, must not use a \"[]\"-suffixed type in sqltypes mode", result)
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
result := mapper.BuildBunTag(tt.column, nil)
if !strings.Contains(result, tt.wantSubstr) {
t.Errorf("BuildBunTag() = %q, missing %q", result, tt.wantSubstr)
}
goType := mapper.SQLTypeToGoType(tt.column.Type, tt.column.NotNull)
if strings.Contains(goType, "sql_types") {
t.Errorf("SQLTypeToGoType() = %q, array columns must use a native Go slice, not an sql_types wrapper", goType)
}
})
}
})
}
}
func TestTypeMapper_BuildBunTag_StdlibArrayHasArrayTag(t *testing.T) {
mapper := NewTypeMapper(writers.NullableTypeStdlib)
// TestTypeMapper_SQLTypeToGoType_ArrayNullable verifies that nullable array
// columns become a pointer-to-slice when NullableArrays is
// "pointer_slice", so callers can distinguish SQL NULL (nil pointer) from
// '{}' (pointer to an empty slice); NOT NULL columns are unaffected.
func TestTypeMapper_SQLTypeToGoType_ArrayNullable(t *testing.T) {
cases := []struct {
name string
column *models.Column
name string
typeStyle string
arrayNullable string
sqlType string
notNull bool
want string
}{
{name: "text array", column: &models.Column{Name: "tags", Type: "text[]"}},
{name: "integer array", column: &models.Column{Name: "scores", Type: "integer[]", NotNull: true}},
{name: "baselib nullable slice (default)", typeStyle: writers.NullableTypeBaselib, arrayNullable: "", sqlType: "text[]", notNull: false, want: "[]string"},
{name: "baselib nullable pointer_slice", typeStyle: writers.NullableTypeBaselib, arrayNullable: writers.NullableArraysPointerSlice, sqlType: "text[]", notNull: false, want: "*[]string"},
{name: "baselib not null pointer_slice unaffected", typeStyle: writers.NullableTypeBaselib, arrayNullable: writers.NullableArraysPointerSlice, sqlType: "text[]", notNull: true, want: "[]string"},
{name: "stdlib nullable pointer_slice", typeStyle: writers.NullableTypeStdlib, arrayNullable: writers.NullableArraysPointerSlice, sqlType: "integer[]", notNull: false, want: "*[]int32"},
{name: "sqltypes nullable pointer_slice (arrays are always native)", typeStyle: writers.NullableTypeSqlTypes, arrayNullable: writers.NullableArraysPointerSlice, sqlType: "text[]", notNull: false, want: "*[]string"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
result := mapper.BuildBunTag(tt.column, nil)
if !strings.Contains(result, "array") {
t.Errorf("BuildBunTag() = %q, expected 'array' in stdlib mode", result)
mapper := NewTypeMapper(tt.typeStyle, tt.arrayNullable)
got := mapper.SQLTypeToGoType(tt.sqlType, tt.notNull)
if got != tt.want {
t.Errorf("SQLTypeToGoType(%q, %v) = %q, want %q", tt.sqlType, tt.notNull, got, tt.want)
}
})
}
@@ -1052,7 +1065,7 @@ func TestExtraFields_InMultiFile(t *testing.T) {
}
func TestTypeMapper_BuildBunTag_PreservesExplicitTypeModifiers(t *testing.T) {
mapper := NewTypeMapper("")
mapper := NewTypeMapper("", "")
col := &models.Column{
Name: "embedding",