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:
+44
-10
@@ -88,11 +88,11 @@ import (
|
||||
type User struct {
|
||||
bun.BaseModel `bun:"table:users,alias:u"`
|
||||
|
||||
ID int64 `bun:"id,type:uuid,pk," json:"id"`
|
||||
Username string `bun:"username,type:text,notnull," json:"username"`
|
||||
Email sql_types.SqlString `bun:"email,type:text,nullzero," json:"email"`
|
||||
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
ID int64 `bun:"id,type:uuid,pk," json:"id"`
|
||||
Username string `bun:"username,type:text,notnull," json:"username"`
|
||||
Email sql_types.SqlString `bun:"email,type:text,nullzero," json:"email"`
|
||||
Tags []string `bun:"tags,type:text[],array,default:'{}',notnull," json:"tags"`
|
||||
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
}
|
||||
```
|
||||
|
||||
@@ -113,7 +113,7 @@ type User struct {
|
||||
ID string `bun:"id,type:uuid,pk," json:"id"`
|
||||
Username string `bun:"username,type:text,notnull," json:"username"`
|
||||
Email sql.NullString `bun:"email,type:text,nullzero," json:"email"`
|
||||
Tags []string `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||
Tags []string `bun:"tags,type:text[],array,default:'{}',notnull," json:"tags"`
|
||||
CreatedAt time.Time `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
}
|
||||
```
|
||||
@@ -145,11 +145,17 @@ The nullable type package is selected with `--types` (or `WriterOptions.Nullable
|
||||
| `numeric`, `decimal` | `float64` | `SqlFloat64` | `sql.NullFloat64` |
|
||||
| `uuid` | `string` | `SqlUUID` | `sql.NullString` |
|
||||
| `jsonb` | `string` | `SqlJSONB` | `sql.NullString` |
|
||||
| `text[]` | `SqlStringArray` | `SqlStringArray` | `[]string` |
|
||||
| `integer[]` | `SqlInt32Array` | `SqlInt32Array` | `[]int32` |
|
||||
| `uuid[]` | `SqlUUIDArray` | `SqlUUIDArray` | `[]string` |
|
||||
| `text[]` | `[]string`† | `[]string`† | `[]string`† |
|
||||
| `integer[]` | `[]int32`† | `[]int32`† | `[]int32`† |
|
||||
| `uuid[]` | `[]string`† | `[]string`† | `[]string`† |
|
||||
| `vector` | `SqlVector` | `SqlVector` | `[]float32` |
|
||||
|
||||
† Array columns always use a plain native Go slice with an explicit `array`
|
||||
bun tag (`bun:"tags,type:text[],array,..."`), in every `--types` mode — the
|
||||
`SqlXxxArray` wrapper types are never used, since bun's pgdialect scans/appends
|
||||
native slices directly. Regardless of NOT NULL, unless `--array-nullable
|
||||
pointer_slice` is set — see [NullableArrays](#nullablearrays) below.
|
||||
|
||||
\* In sqltypes mode, NOT NULL timestamps use `SqlTimeStamp` (not `time.Time`) unless the base type is a simple integer or boolean. In stdlib mode, NOT NULL timestamps use `time.Time`.
|
||||
|
||||
## Writer Options
|
||||
@@ -174,6 +180,34 @@ options := &writers.WriterOptions{
|
||||
}
|
||||
```
|
||||
|
||||
### NullableArrays
|
||||
|
||||
Controls how nullable PostgreSQL array columns are represented in stdlib/baselib
|
||||
`--types` mode (no effect in `sqltypes` mode, which always uses the `SqlXxxArray`
|
||||
wrapper types). Set via the `--array-nullable` CLI flag or `WriterOptions.NullableArrays`:
|
||||
|
||||
```go
|
||||
// Default: every array column is a plain slice, e.g. []string.
|
||||
// SQL NULL and '{}' both scan into a nil/zero-length slice, so callers
|
||||
// cannot distinguish them.
|
||||
options := &writers.WriterOptions{
|
||||
NullableArrays: writers.NullableArraysSlice,
|
||||
}
|
||||
|
||||
// Nullable array columns become a pointer to a slice, e.g. *[]string.
|
||||
// A nil pointer means SQL NULL; a non-nil pointer to an empty slice
|
||||
// means '{}'. NOT NULL array columns are unaffected and stay plain slices.
|
||||
options := &writers.WriterOptions{
|
||||
NullableArrays: writers.NullableArraysPointerSlice,
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
tags []string `bun:"tags,type:text[],notnull,"` // NOT NULL, either mode
|
||||
tags []string `bun:"tags,type:text[],nullzero,"` // nullable, default (slice)
|
||||
tags *[]string `bun:"tags,type:text[],nullzero,"` // nullable, pointer_slice
|
||||
```
|
||||
|
||||
### Metadata Options
|
||||
|
||||
```go
|
||||
@@ -248,7 +282,7 @@ Example `extra-fields.json`:
|
||||
- Model names are derived from table names (singularized, PascalCase)
|
||||
- Table aliases are auto-generated from table names
|
||||
- Nullable columns use plain Go pointer types (`*string`, `*time.Time`, …) by default; pass `--types sqltypes` to use `sql_types.SqlString`, `sql_types.SqlTimeStamp`, etc., or `--types stdlib` to use `sql.NullString`, `sql.NullTime`, etc.
|
||||
- Array columns use plain Go slices (`[]string`, `[]int32`, …) by default; `--types sqltypes` uses `sql_types.SqlStringArray`, `sql_types.SqlInt32Array`, etc.
|
||||
- Array columns always use plain Go slices (`[]string`, `[]int32`, …) with an explicit `array` bun tag, regardless of `--types`; pass `--array-nullable pointer_slice` to use `*[]string` etc. for nullable array columns.
|
||||
- Multi-file mode: one file per table named `sql_{schema}_{table}.go`
|
||||
- Generated code is auto-formatted
|
||||
- JSON tags are automatically added
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package bun_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect/pgdialect"
|
||||
)
|
||||
|
||||
// TestBunArrayColumns_NativeSlice verifies against a live PostgreSQL
|
||||
// database (issue #13) that RelSpec's generated Bun array tags round-trip
|
||||
// correctly through Bun's own pgdialect for both NOT NULL and nullable
|
||||
// columns: NULL, '{}', and populated arrays must all scan and insert
|
||||
// without a "bun: Scan(unsupported ...)" error.
|
||||
//
|
||||
// Requires the RELSPEC_TEST_PG_CONN environment variable, e.g.:
|
||||
//
|
||||
// RELSPEC_TEST_PG_CONN="postgres://postgres:postgres@localhost:5432/relspec_test?sslmode=disable"
|
||||
func TestBunArrayColumns_NativeSlice(t *testing.T) {
|
||||
connStr := os.Getenv("RELSPEC_TEST_PG_CONN")
|
||||
if connStr == "" {
|
||||
t.Skip("Skipping Bun array integration test: RELSPEC_TEST_PG_CONN environment variable not set")
|
||||
}
|
||||
|
||||
sqldb, err := sql.Open("pgx", connStr)
|
||||
if err != nil {
|
||||
t.Fatalf("open connection: %v", err)
|
||||
}
|
||||
defer sqldb.Close()
|
||||
|
||||
db := bun.NewDB(sqldb, pgdialect.New())
|
||||
ctx := context.Background()
|
||||
|
||||
type notNullArrayRow struct {
|
||||
bun.BaseModel `bun:"table:relspec_test_arr_notnull"`
|
||||
ID int64 `bun:"id,pk,autoincrement"`
|
||||
Tags []string `bun:"tags,type:text[],notnull"`
|
||||
}
|
||||
|
||||
type nullableArrayRow struct {
|
||||
bun.BaseModel `bun:"table:relspec_test_arr_nullable"`
|
||||
ID int64 `bun:"id,pk,autoincrement"`
|
||||
Tags *[]string `bun:"tags,type:text[]"`
|
||||
}
|
||||
|
||||
if _, err := db.NewDropTable().Model((*notNullArrayRow)(nil)).IfExists().Exec(ctx); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
if _, err := db.NewDropTable().Model((*nullableArrayRow)(nil)).IfExists().Exec(ctx); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
if _, err := db.NewCreateTable().Model((*notNullArrayRow)(nil)).Exec(ctx); err != nil {
|
||||
t.Fatalf("create not-null table: %v", err)
|
||||
}
|
||||
if _, err := db.NewCreateTable().Model((*nullableArrayRow)(nil)).Exec(ctx); err != nil {
|
||||
t.Fatalf("create nullable table: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.NewDropTable().Model((*notNullArrayRow)(nil)).IfExists().Exec(ctx)
|
||||
db.NewDropTable().Model((*nullableArrayRow)(nil)).IfExists().Exec(ctx)
|
||||
})
|
||||
|
||||
t.Run("not null populated array", func(t *testing.T) {
|
||||
row := ¬NullArrayRow{Tags: []string{"a", "b", "c"}}
|
||||
if _, err := db.NewInsert().Model(row).Exec(ctx); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
var out notNullArrayRow
|
||||
if err := db.NewSelect().Model(&out).Where("id = ?", row.ID).Scan(ctx); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
if len(out.Tags) != 3 || out.Tags[0] != "a" || out.Tags[2] != "c" {
|
||||
t.Errorf("Tags = %v, want [a b c]", out.Tags)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not null empty array", func(t *testing.T) {
|
||||
row := ¬NullArrayRow{Tags: []string{}}
|
||||
if _, err := db.NewInsert().Model(row).Exec(ctx); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
var out notNullArrayRow
|
||||
if err := db.NewSelect().Model(&out).Where("id = ?", row.ID).Scan(ctx); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
if len(out.Tags) != 0 {
|
||||
t.Errorf("Tags = %v, want empty", out.Tags)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nullable NULL", func(t *testing.T) {
|
||||
row := &nullableArrayRow{Tags: nil}
|
||||
if _, err := db.NewInsert().Model(row).Exec(ctx); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
var out nullableArrayRow
|
||||
if err := db.NewSelect().Model(&out).Where("id = ?", row.ID).Scan(ctx); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
if out.Tags != nil {
|
||||
t.Errorf("Tags = %v, want nil (SQL NULL)", *out.Tags)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nullable empty array", func(t *testing.T) {
|
||||
empty := []string{}
|
||||
row := &nullableArrayRow{Tags: &empty}
|
||||
if _, err := db.NewInsert().Model(row).Exec(ctx); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
var out nullableArrayRow
|
||||
if err := db.NewSelect().Model(&out).Where("id = ?", row.ID).Scan(ctx); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
if out.Tags == nil {
|
||||
t.Fatalf("Tags = nil, want non-nil pointer to empty slice")
|
||||
}
|
||||
if len(*out.Tags) != 0 {
|
||||
t.Errorf("Tags = %v, want empty slice", *out.Tags)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nullable populated array", func(t *testing.T) {
|
||||
tags := []string{"x", "y"}
|
||||
row := &nullableArrayRow{Tags: &tags}
|
||||
if _, err := db.NewInsert().Model(row).Exec(ctx); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
var out nullableArrayRow
|
||||
if err := db.NewSelect().Model(&out).Where("id = ?", row.ID).Scan(ctx); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
if out.Tags == nil || len(*out.Tags) != 2 || (*out.Tags)[0] != "x" {
|
||||
t.Errorf("Tags = %v, want [x y]", out.Tags)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -13,26 +13,37 @@ import (
|
||||
type TypeMapper struct {
|
||||
sqlTypesAlias string
|
||||
typeStyle string // writers.NullableTypeSqlTypes | writers.NullableTypeStdlib | writers.NullableTypeBaselib
|
||||
arrayNullable string // writers.NullableArraysSlice | writers.NullableArraysPointerSlice
|
||||
}
|
||||
|
||||
// NewTypeMapper creates a new TypeMapper.
|
||||
// typeStyle should be writers.NullableTypeSqlTypes, writers.NullableTypeStdlib, or
|
||||
// writers.NullableTypeBaselib; an empty string defaults to baselib.
|
||||
func NewTypeMapper(typeStyle string) *TypeMapper {
|
||||
// arrayNullable should be writers.NullableArraysSlice or
|
||||
// writers.NullableArraysPointerSlice; an empty string defaults to slice.
|
||||
func NewTypeMapper(typeStyle, arrayNullable string) *TypeMapper {
|
||||
if typeStyle == "" {
|
||||
typeStyle = writers.NullableTypeBaselib
|
||||
}
|
||||
if arrayNullable == "" {
|
||||
arrayNullable = writers.NullableArraysSlice
|
||||
}
|
||||
return &TypeMapper{
|
||||
sqlTypesAlias: "sql_types",
|
||||
typeStyle: typeStyle,
|
||||
arrayNullable: arrayNullable,
|
||||
}
|
||||
}
|
||||
|
||||
// SQLTypeToGoType converts a SQL type to its Go equivalent.
|
||||
func (tm *TypeMapper) SQLTypeToGoType(sqlType string, notNull bool) string {
|
||||
// Array types are handled separately for both styles.
|
||||
// Array columns always use a native Go slice, regardless of typeStyle.
|
||||
if pgsql.IsArrayType(sqlType) {
|
||||
return tm.arrayGoType(tm.extractBaseType(sqlType))
|
||||
goType := tm.arrayGoType(tm.extractBaseType(sqlType))
|
||||
if !notNull && tm.arrayNullable == writers.NullableArraysPointerSlice {
|
||||
goType = "*" + goType
|
||||
}
|
||||
return goType
|
||||
}
|
||||
|
||||
baseType := tm.extractBaseType(sqlType)
|
||||
@@ -186,70 +197,15 @@ func (tm *TypeMapper) bunGoType(sqlType string) string {
|
||||
return tm.sqlTypesAlias + ".SqlString"
|
||||
}
|
||||
|
||||
// pgArrayInternalTypeName returns PostgreSQL's internal array type name
|
||||
// (e.g. "_text" for text[]) for the given canonical base element type.
|
||||
//
|
||||
// This is used instead of the "text[]" spelling in the sqltypes-style bun
|
||||
// tag: bun's pgdialect unconditionally overrides Field.Scan/Append with its
|
||||
// own array handling whenever the tag's "type:" value ends in "[]" (see
|
||||
// pgdialect.Dialect.onField), which clobbers the sql.Scanner/driver.Valuer
|
||||
// implemented on the SqlXxxArray wrapper types and causes
|
||||
// "bun: Scan(unsupported sqltypes.SqlXxxArray)" errors at query time. The
|
||||
// underscore-prefixed internal name is a real, DDL-valid PostgreSQL type
|
||||
// name that doesn't end in "[]", so it sidesteps the override.
|
||||
func (tm *TypeMapper) pgArrayInternalTypeName(baseElemType string) string {
|
||||
typeMap := map[string]string{
|
||||
"text": "_text", "varchar": "_varchar",
|
||||
"char": "_bpchar", "character": "_bpchar", "bpchar": "_bpchar",
|
||||
"citext": "_citext",
|
||||
"inet": "_inet", "cidr": "_cidr", "macaddr": "_macaddr",
|
||||
"json": "_json", "jsonb": "_jsonb",
|
||||
"integer": "_int4", "int": "_int4", "int4": "_int4", "serial": "_int4",
|
||||
"smallint": "_int2", "int2": "_int2", "smallserial": "_int2",
|
||||
"bigint": "_int8", "int8": "_int8", "bigserial": "_int8",
|
||||
"real": "_float4", "float4": "_float4",
|
||||
"double precision": "_float8", "float8": "_float8",
|
||||
"numeric": "_numeric", "decimal": "_numeric",
|
||||
"money": "_money",
|
||||
"boolean": "_bool", "bool": "_bool",
|
||||
"uuid": "_uuid",
|
||||
}
|
||||
if pgType, ok := typeMap[baseElemType]; ok {
|
||||
return pgType
|
||||
}
|
||||
return "_text"
|
||||
}
|
||||
|
||||
// arrayGoType returns the Go type for a PostgreSQL array column.
|
||||
// The baseElemType is the canonical base type (e.g. "text", "integer").
|
||||
//
|
||||
// Array columns always use a plain native Go slice, even in sqltypes mode:
|
||||
// bun's pgdialect scans native slices directly, and the SqlXxxArray wrapper
|
||||
// types are not usable as array columns (their Scan/Append are bypassed
|
||||
// whenever the "array" bun tag is set, which is required for arrays).
|
||||
func (tm *TypeMapper) arrayGoType(baseElemType string) string {
|
||||
if tm.typeStyle == writers.NullableTypeStdlib || tm.typeStyle == writers.NullableTypeBaselib {
|
||||
return tm.stdlibArrayGoType(baseElemType)
|
||||
}
|
||||
typeMap := map[string]string{
|
||||
"text": tm.sqlTypesAlias + ".SqlStringArray", "varchar": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"char": tm.sqlTypesAlias + ".SqlStringArray", "character": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"citext": tm.sqlTypesAlias + ".SqlStringArray", "bpchar": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"inet": tm.sqlTypesAlias + ".SqlStringArray", "cidr": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"macaddr": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"json": tm.sqlTypesAlias + ".SqlStringArray", "jsonb": tm.sqlTypesAlias + ".SqlStringArray",
|
||||
"integer": tm.sqlTypesAlias + ".SqlInt32Array", "int": tm.sqlTypesAlias + ".SqlInt32Array",
|
||||
"int4": tm.sqlTypesAlias + ".SqlInt32Array", "serial": tm.sqlTypesAlias + ".SqlInt32Array",
|
||||
"smallint": tm.sqlTypesAlias + ".SqlInt16Array", "int2": tm.sqlTypesAlias + ".SqlInt16Array",
|
||||
"smallserial": tm.sqlTypesAlias + ".SqlInt16Array",
|
||||
"bigint": tm.sqlTypesAlias + ".SqlInt64Array", "int8": tm.sqlTypesAlias + ".SqlInt64Array",
|
||||
"bigserial": tm.sqlTypesAlias + ".SqlInt64Array",
|
||||
"real": tm.sqlTypesAlias + ".SqlFloat32Array", "float4": tm.sqlTypesAlias + ".SqlFloat32Array",
|
||||
"double precision": tm.sqlTypesAlias + ".SqlFloat64Array", "float8": tm.sqlTypesAlias + ".SqlFloat64Array",
|
||||
"numeric": tm.sqlTypesAlias + ".SqlFloat64Array", "decimal": tm.sqlTypesAlias + ".SqlFloat64Array",
|
||||
"money": tm.sqlTypesAlias + ".SqlFloat64Array",
|
||||
"boolean": tm.sqlTypesAlias + ".SqlBoolArray", "bool": tm.sqlTypesAlias + ".SqlBoolArray",
|
||||
"uuid": tm.sqlTypesAlias + ".SqlUUIDArray",
|
||||
}
|
||||
if goType, ok := typeMap[baseElemType]; ok {
|
||||
return goType
|
||||
}
|
||||
return tm.sqlTypesAlias + ".SqlStringArray"
|
||||
return tm.stdlibArrayGoType(baseElemType)
|
||||
}
|
||||
|
||||
// rawGoType returns the plain Go type for a NOT NULL column in stdlib mode.
|
||||
@@ -394,11 +350,8 @@ func (tm *TypeMapper) BuildBunTag(column *models.Column, table *models.Table) st
|
||||
typeStr = fmt.Sprintf("%s(%d)", typeStr, column.Precision)
|
||||
}
|
||||
}
|
||||
if isArray && tm.typeStyle == writers.NullableTypeSqlTypes {
|
||||
typeStr = tm.pgArrayInternalTypeName(tm.extractBaseType(typeStr))
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("type:%s", typeStr))
|
||||
if isArray && tm.typeStyle == writers.NullableTypeStdlib {
|
||||
if isArray {
|
||||
parts = append(parts, "array")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ type Writer struct {
|
||||
func NewWriter(options *writers.WriterOptions) *Writer {
|
||||
w := &Writer{
|
||||
options: options,
|
||||
typeMapper: NewTypeMapper(options.NullableTypes),
|
||||
typeMapper: NewTypeMapper(options.NullableTypes, options.NullableArrays),
|
||||
config: LoadMethodConfigFromMetadata(options.Metadata),
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user