// 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() }