diff --git a/pkg/writers/bun/type_mapper.go b/pkg/writers/bun/type_mapper.go index e66f4ae..a1750e0 100644 --- a/pkg/writers/bun/type_mapper.go +++ b/pkg/writers/bun/type_mapper.go @@ -186,6 +186,40 @@ 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"). func (tm *TypeMapper) arrayGoType(baseElemType string) string { @@ -360,6 +394,9 @@ 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 { parts = append(parts, "array") diff --git a/pkg/writers/bun/writer_test.go b/pkg/writers/bun/writer_test.go index 7ef67ae..67a442a 100644 --- a/pkg/writers/bun/writer_test.go +++ b/pkg/writers/bun/writer_test.go @@ -827,9 +827,45 @@ func TestTypeMapper_BuildBunTag(t *testing.T) { t.Errorf("BuildBunTag() = %q, missing %q", result, part) } } - // sqltypes mode must NOT add "array" — SqlXxxArray uses sql.Scanner + // 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 sqltypes mode", result) + t.Errorf("BuildBunTag() = %q, must not contain 'array' in baselib mode", 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) + + 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) + } + if strings.Contains(result, "[]") { + t.Errorf("BuildBunTag() = %q, must not use a \"[]\"-suffixed type in sqltypes mode", result) } }) }