restructure project, add claudemd

This commit is contained in:
2026-07-08 16:36:17 -04:00
parent a7964f9410
commit 2a5fbffaa2
315 changed files with 81075 additions and 0 deletions

554
go/dbutil/automapper.go Normal file
View File

@@ -0,0 +1,554 @@
// 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()
}