555 lines
16 KiB
Go
555 lines
16 KiB
Go
// automapper.go scans sql.Rows into Go structs using column name conventions.
|
|
//
|
|
// The standard library's sql.Rows.Scan requires you to pass a pointer for
|
|
// every column in the result set, in order, which is tedious and fragile.
|
|
// The automapper eliminates that by reflecting on the destination struct,
|
|
// building a map from column names to field index paths, and wiring up the
|
|
// scan targets automatically.
|
|
//
|
|
// # Column-to-field mapping
|
|
//
|
|
// The mapper inspects the destination struct and applies these rules in order:
|
|
//
|
|
// 1. A field with a `db:"col"` tag maps to the column named "col". When the
|
|
// field lives inside a nested model struct, the column name is prefixed:
|
|
// "prefix.col".
|
|
//
|
|
// 2. An anonymous (embedded) struct without a `db` tag is flattened into its
|
|
// parent. Its fields are mapped as if they were declared directly on the
|
|
// parent.
|
|
//
|
|
// 3. A named struct field without a `db` tag, whose type contains at least
|
|
// one `db`-tagged field, is treated as a nested model. Its prefix is taken
|
|
// from the field's `alias` tag if present, otherwise from the snake_case
|
|
// of the field name. For example, a field named CreatedBy of type AppUser
|
|
// gets the prefix "created_by", so its ID column maps to "created_by.id".
|
|
//
|
|
// 4. Everything else (unexported fields, fields without `db` tags whose types
|
|
// have no `db`-tagged fields) is skipped.
|
|
//
|
|
// The mapping is computed once per destination type and cached with a
|
|
// sync.RWMutex for concurrent safety.
|
|
//
|
|
// # How it connects to the query builder
|
|
//
|
|
// The builder's Cols method generates SELECT expressions with aliases that
|
|
// match this mapping convention. For a table reference like
|
|
// T[models.AppUser]("au"), Cols produces:
|
|
//
|
|
// au.id AS "app_user.id", au.username AS "app_user.username", ...
|
|
//
|
|
// When the automapper sees "app_user.id" in the result columns, it looks up
|
|
// that key and finds the field path into the AppUser nested struct in the
|
|
// destination DTO. This is what lets a single scan call populate a multi-model
|
|
// DTO from a JOIN query.
|
|
//
|
|
// For single-table queries where no prefix is needed, use ColsFlat instead.
|
|
// It produces bare expressions like "au.id, au.username, ..." which map
|
|
// directly to `db` tags without a prefix.
|
|
//
|
|
// # LEFT JOIN nil detection
|
|
//
|
|
// When a destination struct has a pointer-to-struct field (e.g. *AppUser),
|
|
// the mapper tracks it as a potential LEFT JOIN result. Before scanning, it
|
|
// allocates the pointed-to struct so the driver has somewhere to write values.
|
|
// After scanning, if every field in that struct is still its zero value, the
|
|
// mapper nils the pointer back out. This gives you a clean nil when the LEFT
|
|
// JOIN matched no rows, instead of a struct full of zero values.
|
|
//
|
|
// # Public API
|
|
//
|
|
// - ScanOne scans a single row into a struct pointer. Returns sql.ErrNoRows
|
|
// if no row is available.
|
|
// - ScanAll scans all remaining rows into a slice of structs.
|
|
// - QueryOne and QueryAll are convenience wrappers that execute a query and
|
|
// scan in one call.
|
|
// - QueryScalar scans a single scalar value (int, string, etc.) without
|
|
// struct mapping.
|
|
// - Columns generates the aliased SELECT expressions for a model type.
|
|
// - DebugMapping returns the full column-to-field-path map for a type,
|
|
// useful for troubleshooting mismatches.
|
|
|
|
package dbutil
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"unicode"
|
|
)
|
|
|
|
// Querier is the interface for types that can execute SQL queries.
|
|
// *sql.DB, *sql.Tx, and *sql.Conn all implement this.
|
|
type Querier interface {
|
|
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
|
}
|
|
|
|
var (
|
|
cacheMu sync.RWMutex
|
|
mappingCache = make(map[reflect.Type]typeMapping)
|
|
)
|
|
|
|
type typeMapping struct {
|
|
columns map[string][]int // column name -> field index path
|
|
ptrStructs [][]int // field index paths of pointer-to-struct fields
|
|
}
|
|
|
|
// ScanOne scans the next row from rows into dest.
|
|
// dest must be a pointer to a struct. Does not close rows.
|
|
func ScanOne(rows *sql.Rows, dest any) error {
|
|
dv := reflect.ValueOf(dest)
|
|
if dv.Kind() != reflect.Ptr || dv.Elem().Kind() != reflect.Struct {
|
|
return fmt.Errorf("automapper: dest must be a pointer to a struct, got %T", dest)
|
|
}
|
|
|
|
columns, err := rows.Columns()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tm := getMapping(dv.Elem().Type())
|
|
|
|
if !rows.Next() {
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
return sql.ErrNoRows
|
|
}
|
|
|
|
return scanRow(rows, columns, tm, dv.Elem())
|
|
}
|
|
|
|
// ScanAll scans all remaining rows into dest.
|
|
// dest must be a pointer to a slice of structs (or pointer-to-structs).
|
|
// Does not close rows.
|
|
func ScanAll(rows *sql.Rows, dest any) error {
|
|
dv := reflect.ValueOf(dest)
|
|
if dv.Kind() != reflect.Ptr || dv.Elem().Kind() != reflect.Slice {
|
|
return fmt.Errorf("automapper: dest must be a pointer to a slice, got %T", dest)
|
|
}
|
|
|
|
sliceVal := dv.Elem()
|
|
elemType := sliceVal.Type().Elem()
|
|
isPtr := elemType.Kind() == reflect.Ptr
|
|
if isPtr {
|
|
elemType = elemType.Elem()
|
|
}
|
|
if elemType.Kind() != reflect.Struct {
|
|
return fmt.Errorf("automapper: slice element must be a struct or *struct, got %s", elemType.Kind())
|
|
}
|
|
|
|
columns, err := rows.Columns()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tm := getMapping(elemType)
|
|
|
|
for rows.Next() {
|
|
elem := reflect.New(elemType).Elem()
|
|
if err := scanRow(rows, columns, tm, elem); err != nil {
|
|
return err
|
|
}
|
|
if isPtr {
|
|
ptr := reflect.New(elemType)
|
|
ptr.Elem().Set(elem)
|
|
sliceVal.Set(reflect.Append(sliceVal, ptr))
|
|
} else {
|
|
sliceVal.Set(reflect.Append(sliceVal, elem))
|
|
}
|
|
}
|
|
|
|
return rows.Err()
|
|
}
|
|
|
|
// QueryOne executes query and scans a single row into dest.
|
|
// dest must be a pointer to a struct.
|
|
func QueryOne(ctx context.Context, db Querier, dest any, query string, args ...any) error {
|
|
rows, err := db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
return ScanOne(rows, dest)
|
|
}
|
|
|
|
// QueryAll executes query and scans all rows into dest.
|
|
// dest must be a pointer to a slice of structs.
|
|
func QueryAll(ctx context.Context, db Querier, dest any, query string, args ...any) error {
|
|
rows, err := db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
return ScanAll(rows, dest)
|
|
}
|
|
|
|
// QueryScalar executes query and scans a single scalar value.
|
|
func QueryScalar[T any](ctx context.Context, db Querier, query string, args ...any) (T, error) {
|
|
var result T
|
|
rows, err := db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
if !rows.Next() {
|
|
if err := rows.Err(); err != nil {
|
|
return result, err
|
|
}
|
|
return result, sql.ErrNoRows
|
|
}
|
|
err = rows.Scan(&result)
|
|
return result, err
|
|
}
|
|
|
|
// Columns generates SELECT column expressions for a model struct, aliased for
|
|
// the automapper to route into nested destination structs.
|
|
//
|
|
// For a type like model.AppUser with db tags, and tableAlias "au":
|
|
//
|
|
// Columns(model.AppUser{}, "au")
|
|
// -> au.id AS "app_user.id", au.username AS "app_user.username", ...
|
|
//
|
|
// The mapping prefix defaults to the snake_case of the type name. Override it
|
|
// with a third argument to match an `alias` tag on the destination field:
|
|
//
|
|
// Columns(model.AppUser{}, "cb", "created_by")
|
|
// -> cb.id AS "created_by.id", cb.username AS "created_by.username", ...
|
|
func Columns(model any, tableAlias string, mappingPrefix ...string) string {
|
|
t := reflect.TypeOf(model)
|
|
if t.Kind() == reflect.Ptr {
|
|
t = t.Elem()
|
|
}
|
|
prefix := toSnakeCase(t.Name())
|
|
if len(mappingPrefix) > 0 && mappingPrefix[0] != "" {
|
|
prefix = mappingPrefix[0]
|
|
}
|
|
cols := collectColumns(t, tableAlias, prefix)
|
|
return strings.Join(cols, ", ")
|
|
}
|
|
|
|
// DebugMapping returns the column-to-field mapping for a given struct type.
|
|
// Useful for verifying that your SQL column aliases match the expected mapping.
|
|
func DebugMapping(dest any) map[string]string {
|
|
t := reflect.TypeOf(dest)
|
|
if t.Kind() == reflect.Ptr {
|
|
t = t.Elem()
|
|
}
|
|
tm := getMapping(t)
|
|
result := make(map[string]string, len(tm.columns))
|
|
for col, idx := range tm.columns {
|
|
result[col] = fieldPathString(t, idx)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// internal
|
|
|
|
func scanRow(rows *sql.Rows, columns []string, tm typeMapping, dest reflect.Value) error {
|
|
// Allocate pointer-to-struct fields so we can scan into them
|
|
for _, idx := range tm.ptrStructs {
|
|
f := dest.FieldByIndex(idx)
|
|
if f.IsNil() {
|
|
f.Set(reflect.New(f.Type().Elem()))
|
|
}
|
|
}
|
|
|
|
// Build scan targets
|
|
targets := make([]any, len(columns))
|
|
for i, col := range columns {
|
|
if idx, ok := tm.columns[col]; ok {
|
|
f := fieldByIndex(dest, idx)
|
|
if isInsidePtrStruct(idx, tm.ptrStructs) && f.Kind() != reflect.Ptr {
|
|
// Non-pointer field inside a LEFT JOIN struct.
|
|
// database/sql cannot scan NULL into non-pointer types,
|
|
// so wrap in a nullSafeScanner that absorbs NULLs.
|
|
targets[i] = &nullSafeScanner{field: f}
|
|
} else {
|
|
targets[i] = f.Addr().Interface()
|
|
}
|
|
} else {
|
|
// Unmapped column -- discard
|
|
targets[i] = new(sql.RawBytes)
|
|
}
|
|
}
|
|
|
|
if err := rows.Scan(targets...); err != nil {
|
|
return err
|
|
}
|
|
|
|
// For LEFT JOINs: nil out pointer-to-struct fields where every column
|
|
// scanned to its zero value (the entire joined row was NULL).
|
|
for _, idx := range tm.ptrStructs {
|
|
f := dest.FieldByIndex(idx)
|
|
if f.Elem().IsZero() {
|
|
f.Set(reflect.Zero(f.Type()))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// nullSafeScanner implements sql.Scanner for non-pointer struct fields that
|
|
// live inside a LEFT JOIN (pointer-to-struct) target. When the LEFT JOIN
|
|
// produces no match, every column is NULL. database/sql can't scan NULL into
|
|
// non-pointer types like string or int32, so this wrapper absorbs NULLs
|
|
// (leaving the field at its zero value) and delegates non-NULL values to the
|
|
// field's own Scanner or to database/sql's built-in conversion.
|
|
type nullSafeScanner struct {
|
|
field reflect.Value
|
|
}
|
|
|
|
func (n *nullSafeScanner) Scan(src any) error {
|
|
if src == nil {
|
|
return nil
|
|
}
|
|
// If the field's address implements sql.Scanner, delegate to it.
|
|
addr := n.field.Addr().Interface()
|
|
if scanner, ok := addr.(sql.Scanner); ok {
|
|
return scanner.Scan(src)
|
|
}
|
|
// Otherwise, use reflect to assign compatible types directly.
|
|
sv := reflect.ValueOf(src)
|
|
ft := n.field.Type()
|
|
if sv.Type().AssignableTo(ft) {
|
|
n.field.Set(sv)
|
|
return nil
|
|
}
|
|
if sv.Type().ConvertibleTo(ft) {
|
|
n.field.Set(sv.Convert(ft))
|
|
return nil
|
|
}
|
|
return fmt.Errorf("automapper: cannot convert %T to %s", src, ft)
|
|
}
|
|
|
|
// isInsidePtrStruct reports whether the field at idx is a descendant of any
|
|
// pointer-to-struct field tracked for LEFT JOIN nil detection.
|
|
func isInsidePtrStruct(idx []int, ptrStructs [][]int) bool {
|
|
for _, ps := range ptrStructs {
|
|
if len(idx) > len(ps) {
|
|
match := true
|
|
for i, v := range ps {
|
|
if idx[i] != v {
|
|
match = false
|
|
break
|
|
}
|
|
}
|
|
if match {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// fieldByIndex walks a field index path, dereferencing pointers along the way.
|
|
func fieldByIndex(v reflect.Value, index []int) reflect.Value {
|
|
for _, i := range index {
|
|
if v.Kind() == reflect.Ptr {
|
|
v = v.Elem()
|
|
}
|
|
v = v.Field(i)
|
|
}
|
|
return v
|
|
}
|
|
|
|
func getMapping(t reflect.Type) typeMapping {
|
|
cacheMu.RLock()
|
|
if tm, ok := mappingCache[t]; ok {
|
|
cacheMu.RUnlock()
|
|
return tm
|
|
}
|
|
cacheMu.RUnlock()
|
|
|
|
cacheMu.Lock()
|
|
defer cacheMu.Unlock()
|
|
|
|
// Double-check after write lock
|
|
if tm, ok := mappingCache[t]; ok {
|
|
return tm
|
|
}
|
|
|
|
tm := typeMapping{columns: make(map[string][]int)}
|
|
buildColumns(t, "", nil, tm.columns)
|
|
tm.ptrStructs = findPtrStructs(t, nil)
|
|
mappingCache[t] = tm
|
|
return tm
|
|
}
|
|
|
|
// buildColumns recursively maps column names -> field index paths.
|
|
//
|
|
// Mapping rules:
|
|
// 1. Field has `db:"col"` tag -> maps to "col" (or "prefix.col" when nested)
|
|
// 2. Anonymous (embedded) struct without `db` tag -> flattened into parent
|
|
// 3. Named struct field without `db` tag, whose type has db-tagged fields ->
|
|
// nested model. Prefix comes from `alias` tag or snake_case of field name.
|
|
// 4. Everything else is skipped.
|
|
func buildColumns(t reflect.Type, prefix string, parent []int, columns map[string][]int) {
|
|
for i := range t.NumField() {
|
|
field := t.Field(i)
|
|
if !field.IsExported() {
|
|
continue
|
|
}
|
|
|
|
idx := appendIndex(parent, i)
|
|
|
|
// Rule 1: leaf column
|
|
if dbTag := field.Tag.Get("db"); dbTag != "" && dbTag != "-" {
|
|
col := dbTag
|
|
if prefix != "" {
|
|
col = prefix + "." + dbTag
|
|
}
|
|
columns[col] = idx
|
|
continue
|
|
}
|
|
|
|
// Struct field without db tag -- check for nested model or embedding
|
|
ft := derefType(field.Type)
|
|
if ft.Kind() != reflect.Struct || !isModelStruct(ft) {
|
|
continue
|
|
}
|
|
|
|
if field.Anonymous {
|
|
// Rule 2: embedded -- flatten
|
|
buildColumns(ft, prefix, idx, columns)
|
|
} else {
|
|
// Rule 3: named nested model
|
|
nestedPrefix := field.Tag.Get("alias")
|
|
if nestedPrefix == "" {
|
|
nestedPrefix = toSnakeCase(field.Name)
|
|
}
|
|
if prefix != "" {
|
|
nestedPrefix = prefix + "." + nestedPrefix
|
|
}
|
|
buildColumns(ft, nestedPrefix, idx, columns)
|
|
}
|
|
}
|
|
}
|
|
|
|
// findPtrStructs returns field index paths of all pointer-to-struct fields
|
|
// whose pointed-to type is a model struct. Used for LEFT JOIN nil detection.
|
|
func findPtrStructs(t reflect.Type, parent []int) [][]int {
|
|
var result [][]int
|
|
for i := range t.NumField() {
|
|
field := t.Field(i)
|
|
if !field.IsExported() {
|
|
continue
|
|
}
|
|
|
|
idx := appendIndex(parent, i)
|
|
|
|
if field.Type.Kind() == reflect.Ptr {
|
|
inner := field.Type.Elem()
|
|
if inner.Kind() == reflect.Struct && isModelStruct(inner) {
|
|
result = append(result, idx)
|
|
result = append(result, findPtrStructs(inner, idx)...)
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Recurse into non-pointer nested model structs
|
|
ft := derefType(field.Type)
|
|
if field.Tag.Get("db") == "" && ft.Kind() == reflect.Struct && isModelStruct(ft) {
|
|
result = append(result, findPtrStructs(ft, idx)...)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// isModelStruct reports whether t (or any of its embedded structs) contains
|
|
// at least one field with a "db" tag. This distinguishes model structs
|
|
// (e.g. model.AppUser) from value types (e.g. time.Time, uuid.UUID).
|
|
func isModelStruct(t reflect.Type) bool {
|
|
for i := range t.NumField() {
|
|
f := t.Field(i)
|
|
if tag := f.Tag.Get("db"); tag != "" && tag != "-" {
|
|
return true
|
|
}
|
|
if f.Anonymous {
|
|
ft := derefType(f.Type)
|
|
if ft.Kind() == reflect.Struct && isModelStruct(ft) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func collectColumns(t reflect.Type, tableAlias, prefix string) []string {
|
|
var cols []string
|
|
for i := range t.NumField() {
|
|
f := t.Field(i)
|
|
if !f.IsExported() {
|
|
continue
|
|
}
|
|
if dbTag := f.Tag.Get("db"); dbTag != "" && dbTag != "-" {
|
|
if tableAlias != "" {
|
|
cols = append(cols, fmt.Sprintf(`%s.%s AS "%s.%s"`, tableAlias, dbTag, prefix, dbTag))
|
|
} else {
|
|
cols = append(cols, fmt.Sprintf(`%s AS "%s.%s"`, dbTag, prefix, dbTag))
|
|
}
|
|
continue
|
|
}
|
|
// Flatten embedded structs
|
|
if f.Anonymous {
|
|
ft := derefType(f.Type)
|
|
if ft.Kind() == reflect.Struct {
|
|
cols = append(cols, collectColumns(ft, tableAlias, prefix)...)
|
|
}
|
|
}
|
|
}
|
|
return cols
|
|
}
|
|
|
|
func fieldPathString(t reflect.Type, index []int) string {
|
|
var parts []string
|
|
for _, i := range index {
|
|
if t.Kind() == reflect.Ptr {
|
|
t = t.Elem()
|
|
}
|
|
f := t.Field(i)
|
|
parts = append(parts, f.Name)
|
|
t = f.Type
|
|
}
|
|
return strings.Join(parts, ".")
|
|
}
|
|
|
|
func derefType(t reflect.Type) reflect.Type {
|
|
for t.Kind() == reflect.Ptr {
|
|
t = t.Elem()
|
|
}
|
|
return t
|
|
}
|
|
|
|
func appendIndex(parent []int, i int) []int {
|
|
idx := make([]int, len(parent)+1)
|
|
copy(idx, parent)
|
|
idx[len(parent)] = i
|
|
return idx
|
|
}
|
|
|
|
// toSnakeCase converts CamelCase/PascalCase to snake_case.
|
|
// Handles consecutive uppercase correctly: "IPAddr" -> "ip_addr", "OrgUserDTO" -> "org_user_dto".
|
|
func toSnakeCase(s string) string {
|
|
var b strings.Builder
|
|
runes := []rune(s)
|
|
for i, r := range runes {
|
|
if unicode.IsUpper(r) {
|
|
if i > 0 {
|
|
prev := runes[i-1]
|
|
if !unicode.IsUpper(prev) || (i+1 < len(runes) && unicode.IsLower(runes[i+1])) {
|
|
b.WriteRune('_')
|
|
}
|
|
}
|
|
b.WriteRune(unicode.ToLower(r))
|
|
} else {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|