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
+75
View File
@@ -0,0 +1,75 @@
package sqlschema
import (
"fmt"
"github.com/uptrace/bun/schema"
)
type Column interface {
GetName() string
GetSQLType() string
GetVarcharLen() int
GetDefaultValue() string
GetIsNullable() bool
GetIsAutoIncrement() bool
GetIsIdentity() bool
AppendQuery(schema.QueryGen, []byte) ([]byte, error)
}
var _ Column = (*BaseColumn)(nil)
// BaseColumn is a base column definition that stores various attributes of a column.
//
// Dialects and only dialects can use it to implement the Column interface.
// Other packages must use the Column interface.
type BaseColumn struct {
Name string
SQLType string
VarcharLen int
DefaultValue string
IsNullable bool
IsAutoIncrement bool
IsIdentity bool
// TODO: add Precision and Cardinality for timestamps/bit-strings/floats and arrays respectively.
}
func (cd BaseColumn) GetName() string {
return cd.Name
}
func (cd BaseColumn) GetSQLType() string {
return cd.SQLType
}
func (cd BaseColumn) GetVarcharLen() int {
return cd.VarcharLen
}
func (cd BaseColumn) GetDefaultValue() string {
return cd.DefaultValue
}
func (cd BaseColumn) GetIsNullable() bool {
return cd.IsNullable
}
func (cd BaseColumn) GetIsAutoIncrement() bool {
return cd.IsAutoIncrement
}
func (cd BaseColumn) GetIsIdentity() bool {
return cd.IsIdentity
}
// AppendQuery appends full SQL data type.
func (c *BaseColumn) AppendQuery(gen schema.QueryGen, b []byte) (_ []byte, err error) {
b = append(b, c.SQLType...)
if c.VarcharLen == 0 {
return b, nil
}
b = append(b, "("...)
b = append(b, fmt.Sprint(c.VarcharLen)...)
b = append(b, ")"...)
return b, nil
}
+128
View File
@@ -0,0 +1,128 @@
package sqlschema
import (
"slices"
"strings"
"github.com/uptrace/bun/schema"
)
type Database interface {
GetTables() []Table
GetForeignKeys() map[ForeignKey]string
}
var _ Database = (*BaseDatabase)(nil)
// BaseDatabase is a base database definition.
//
// Dialects and only dialects can use it to implement the Database interface.
// Other packages must use the Database interface.
type BaseDatabase struct {
Tables []Table
ForeignKeys map[ForeignKey]string
}
func (ds BaseDatabase) GetTables() []Table {
return ds.Tables
}
func (ds BaseDatabase) GetForeignKeys() map[ForeignKey]string {
return ds.ForeignKeys
}
// ForeignKey represents a foreign key constraint between two tables.
type ForeignKey struct {
From ColumnReference
To ColumnReference
}
func NewColumnReference(tableName string, columns ...string) ColumnReference {
return ColumnReference{
TableName: tableName,
Column: NewColumns(columns...),
}
}
func (fk ForeignKey) DependsOnTable(tableName string) bool {
return fk.From.TableName == tableName || fk.To.TableName == tableName
}
func (fk ForeignKey) DependsOnColumn(tableName string, column string) bool {
return fk.DependsOnTable(tableName) &&
(fk.From.Column.Contains(column) || fk.To.Column.Contains(column))
}
// Columns is a hashable representation of []string used to define schema constraints that depend on multiple columns.
// Although having duplicated column references in these constraints is illegal, Columns neither validates nor enforces this constraint on the caller.
type Columns string
// NewColumns creates a composite column from a slice of column names.
func NewColumns(columns ...string) Columns {
slices.Sort(columns)
return Columns(strings.Join(columns, ","))
}
func (c *Columns) String() string {
return string(*c)
}
func (c *Columns) AppendQuery(gen schema.QueryGen, b []byte) ([]byte, error) {
return schema.Safe(*c).AppendQuery(gen, b)
}
// Split returns a slice of column names that make up the composite.
func (c *Columns) Split() []string {
return strings.Split(c.String(), ",")
}
// ContainsColumns checks that columns in "other" are a subset of current colums.
func (c *Columns) ContainsColumns(other Columns) bool {
columns := c.Split()
Outer:
for _, check := range other.Split() {
for _, column := range columns {
if check == column {
continue Outer
}
}
return false
}
return true
}
// Contains checks that a composite column contains the current column.
func (c *Columns) Contains(other string) bool {
return c.ContainsColumns(Columns(other))
}
// Replace renames a column if it is part of the composite.
// If a composite consists of multiple columns, only one column will be renamed.
func (c *Columns) Replace(oldColumn, newColumn string) bool {
columns := c.Split()
for i, column := range columns {
if column == oldColumn {
columns[i] = newColumn
*c = NewColumns(columns...)
return true
}
}
return false
}
// Unique represents a unique constraint defined on 1 or more columns.
type Unique struct {
Name string
Columns Columns
}
// Equals checks that two unique constraint are the same, assuming both are defined for the same table.
func (u Unique) Equals(other Unique) bool {
return u.Columns == other.Columns
}
// ColumnReference identifies a column or set of columns in a table.
type ColumnReference struct {
TableName string
Column Columns
}
+274
View File
@@ -0,0 +1,274 @@
package sqlschema
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/uptrace/bun"
"github.com/uptrace/bun/schema"
)
type InspectorDialect interface {
schema.Dialect
// Inspector returns a new instance of Inspector for the dialect.
// Dialects MAY set their default InspectorConfig values in constructor
// but MUST apply InspectorOptions to ensure they can be overriden.
//
// Use ApplyInspectorOptions to reduce boilerplate.
NewInspector(db *bun.DB, options ...InspectorOption) Inspector
// CompareType returns true if col1 and co2 SQL types are equivalent,
// i.e. they might use dialect-specifc type aliases (SERIAL ~ SMALLINT)
// or specify the same VARCHAR length differently (VARCHAR(255) ~ VARCHAR).
CompareType(Column, Column) bool
}
// InspectorConfig controls the scope of migration by limiting the objects Inspector should return.
// Inspectors SHOULD use the configuration directly instead of copying it, or MAY choose to embed it,
// to make sure options are always applied correctly.
//
// ExcludeTables and ExcludeForeignKeys are intended for database inspectors,
// to compensate for the fact that model structs may not wholly reflect the
// state of the database schema.
// Database inspectors MUST respect these exclusions to prevent relations
// from being dropped unintentionally.
type InspectorConfig struct {
// SchemaName limits inspection to tables in a particular schema.
SchemaName string
// ExcludeTables from inspection. Patterns MAY make use of wildcards
// like % and _ and dialects MUST acknowledge that by using them
// with the SQL LIKE operator.
ExcludeTables []string
// ExcludeForeignKeys from inspection.
ExcludeForeignKeys map[ForeignKey]string
}
// Inspector reads schema state.
type Inspector interface {
Inspect(ctx context.Context) (Database, error)
}
func WithSchemaName(schemaName string) InspectorOption {
return func(cfg *InspectorConfig) {
cfg.SchemaName = schemaName
}
}
// WithExcludeTables forces inspector to exclude tables from the reported schema state.
// It works in append-only mode, i.e. tables cannot be re-included.
//
// Patterns MAY make use of % and _ wildcards, as if writing a LIKE clause in SQL.
func WithExcludeTables(tables ...string) InspectorOption {
return func(cfg *InspectorConfig) {
cfg.ExcludeTables = append(cfg.ExcludeTables, tables...)
}
}
// WithExcludeForeignKeys forces inspector to exclude foreign keys
// from the reported schema state.
func WithExcludeForeignKeys(fks ...ForeignKey) InspectorOption {
return func(cfg *InspectorConfig) {
for _, fk := range fks {
cfg.ExcludeForeignKeys[fk] = ""
}
}
}
// NewInspector creates a new database inspector, if the dialect supports it.
func NewInspector(db *bun.DB, options ...InspectorOption) (Inspector, error) {
dialect, ok := (db.Dialect()).(InspectorDialect)
if !ok {
return nil, fmt.Errorf("%s does not implement sqlschema.Inspector", db.Dialect().Name())
}
return &inspector{
Inspector: dialect.NewInspector(db, options...),
}, nil
}
func NewBunModelInspector(tables *schema.Tables, options ...InspectorOption) *BunModelInspector {
bmi := &BunModelInspector{
tables: tables,
}
ApplyInspectorOptions(&bmi.InspectorConfig, options...)
return bmi
}
// InspectorOption configures an Inspector.
type InspectorOption func(*InspectorConfig)
// ApplyInspectorOptions applies the given options to an InspectorConfig.
func ApplyInspectorOptions(cfg *InspectorConfig, options ...InspectorOption) {
if cfg.ExcludeForeignKeys == nil {
cfg.ExcludeForeignKeys = make(map[ForeignKey]string)
}
for _, opt := range options {
opt(cfg)
}
}
// inspector is opaque pointer to a database inspector.
type inspector struct {
Inspector
}
// BunModelInspector creates the current project state from the passed bun.Models.
// Do not recycle BunModelInspector for different sets of models, as older models will not be de-registerred before the next run.
//
// BunModelInspector does not know which the database's dialect, so it does not
// assume any default schema name. Always specify the target schema name via
// WithSchemaName option to receive meaningful results.
type BunModelInspector struct {
InspectorConfig
tables *schema.Tables
}
var _ Inspector = (*BunModelInspector)(nil)
func (bmi *BunModelInspector) Inspect(ctx context.Context) (Database, error) {
state := BunModelSchema{
BaseDatabase: BaseDatabase{
ForeignKeys: make(map[ForeignKey]string),
},
}
for _, t := range bmi.tables.All() {
if t.Schema != bmi.SchemaName {
continue
}
var columns []Column
for _, f := range t.Fields {
sqlType, length, err := parseLen(f.CreateTableSQLType)
if err != nil {
return nil, fmt.Errorf("parse length in %q: %w", f.CreateTableSQLType, err)
}
columns = append(columns, &BaseColumn{
Name: f.Name,
SQLType: strings.ToLower(sqlType), // TODO(dyma): maybe this is not necessary after Column.Eq()
VarcharLen: length,
DefaultValue: exprOrLiteral(f.SQLDefault),
IsNullable: !f.NotNull,
IsAutoIncrement: f.AutoIncrement,
IsIdentity: f.Identity,
})
}
var unique []Unique
for name, group := range t.Unique {
// Create a separate unique index for single-column unique constraints
// let each dialect apply the default naming convention.
if name == "" {
for _, f := range group {
unique = append(unique, Unique{Columns: NewColumns(f.Name)})
}
continue
}
// Set the name if it is a "unique group", in which case the user has provided the name.
var columns []string
for _, f := range group {
columns = append(columns, f.Name)
}
unique = append(unique, Unique{Name: name, Columns: NewColumns(columns...)})
}
var pk *PrimaryKey
if len(t.PKs) > 0 {
var columns []string
for _, f := range t.PKs {
columns = append(columns, f.Name)
}
pk = &PrimaryKey{Columns: NewColumns(columns...)}
}
// In cases where a table is defined in a non-default schema in the `bun:table` tag,
// schema.Table only extracts the name of the schema, but passes the entire tag value to t.Name
// for backwads-compatibility. For example, a bun model like this:
// type Model struct { bun.BaseModel `bun:"table:favourite.books` }
// produces
// schema.Table{ Schema: "favourite", Name: "favourite.books" }
tableName := strings.TrimPrefix(t.Name, t.Schema+".")
state.Tables = append(state.Tables, &BunTable{
BaseTable: BaseTable{
Schema: t.Schema,
Name: tableName,
Columns: columns,
UniqueConstraints: unique,
PrimaryKey: pk,
},
Model: t.ZeroIface,
})
for _, rel := range t.Relations {
// These relations are nominal and do not need a foreign key to be declared in the current table.
// They will be either expressed as N:1 relations in an m2m mapping table, or will be referenced by the other table if it's a 1:N.
if rel.Type == schema.ManyToManyRelation ||
rel.Type == schema.HasManyRelation {
continue
}
var fromCols, toCols []string
for _, f := range rel.BasePKs {
fromCols = append(fromCols, f.Name)
}
for _, f := range rel.JoinPKs {
toCols = append(toCols, f.Name)
}
target := rel.JoinTable
state.ForeignKeys[ForeignKey{
From: NewColumnReference(t.Name, fromCols...),
To: NewColumnReference(target.Name, toCols...),
}] = ""
}
}
return state, nil
}
func parseLen(typ string) (string, int, error) {
paren := strings.Index(typ, "(")
if paren == -1 {
return typ, 0, nil
}
length, err := strconv.Atoi(typ[paren+1 : len(typ)-1])
if err != nil {
return typ, 0, err
}
return typ[:paren], length, nil
}
// exprOrLiteral converts string to lowercase, if it does not contain a string literal 'lit'
// and trims the surrounding ” otherwise.
// Use it to ensure that user-defined default values in the models are always comparable
// to those returned by the database inspector, regardless of the case convention in individual drivers.
func exprOrLiteral(s string) string {
if strings.HasPrefix(s, "'") && strings.HasSuffix(s, "'") {
return strings.Trim(s, "'")
}
return strings.ToLower(s)
}
// BunModelSchema is the schema state derived from bun table models.
type BunModelSchema struct {
BaseDatabase
Tables []Table
}
func (ms BunModelSchema) GetTables() []Table {
return ms.Tables
}
// BunTable provides additional table metadata that is only accessible from scanning bun models.
type BunTable struct {
BaseTable
// Model stores the zero interface to the underlying Go struct.
Model any
}
+51
View File
@@ -0,0 +1,51 @@
package sqlschema
import (
"fmt"
"github.com/uptrace/bun"
"github.com/uptrace/bun/schema"
)
// MigratorDialect is a Dialect that can create a Migrator for executing schema changes.
type MigratorDialect interface {
schema.Dialect
NewMigrator(db *bun.DB, schemaName string) Migrator
}
// Migrator renders schema-change operations as SQL.
type Migrator interface {
AppendSQL(b []byte, operation any) ([]byte, error)
}
// migrator is a dialect-agnostic wrapper for sqlschema.MigratorDialect.
type migrator struct {
Migrator
}
func NewMigrator(db *bun.DB, schemaName string) (Migrator, error) {
md, ok := db.Dialect().(MigratorDialect)
if !ok {
return nil, fmt.Errorf("%q dialect does not implement sqlschema.Migrator", db.Dialect().Name())
}
return &migrator{
Migrator: md.NewMigrator(db, schemaName),
}, nil
}
// BaseMigrator can be embeded by dialect's Migrator implementations to re-use some of the existing bun queries.
type BaseMigrator struct {
db *bun.DB
}
func NewBaseMigrator(db *bun.DB) *BaseMigrator {
return &BaseMigrator{db: db}
}
func (m *BaseMigrator) AppendCreateTable(b []byte, model any) ([]byte, error) {
return m.db.NewCreateTable().Model(model).AppendQuery(m.db.QueryGen(), b)
}
func (m *BaseMigrator) AppendDropTable(b []byte, schemaName, tableName string) ([]byte, error) {
return m.db.NewDropTable().TableExpr("?.?", bun.Ident(schemaName), bun.Ident(tableName)).AppendQuery(m.db.QueryGen(), b)
}
+56
View File
@@ -0,0 +1,56 @@
package sqlschema
type Table interface {
GetSchema() string
GetName() string
GetColumns() []Column
GetPrimaryKey() *PrimaryKey
GetUniqueConstraints() []Unique
}
var _ Table = (*BaseTable)(nil)
// BaseTable is a base table definition.
//
// Dialects and only dialects can use it to implement the Table interface.
// Other packages must use the Table interface.
type BaseTable struct {
Schema string
Name string
// ColumnDefinitions map each column name to the column definition.
Columns []Column
// PrimaryKey holds the primary key definition.
// A nil value means that no primary key is defined for the table.
PrimaryKey *PrimaryKey
// UniqueConstraints defined on the table.
UniqueConstraints []Unique
}
// PrimaryKey represents a primary key constraint defined on 1 or more columns.
type PrimaryKey struct {
Name string
Columns Columns
}
func (td *BaseTable) GetSchema() string {
return td.Schema
}
func (td *BaseTable) GetName() string {
return td.Name
}
func (td *BaseTable) GetColumns() []Column {
return td.Columns
}
func (td *BaseTable) GetPrimaryKey() *PrimaryKey {
return td.PrimaryKey
}
func (td *BaseTable) GetUniqueConstraints() []Unique {
return td.UniqueConstraints
}