Initial add backend stuff

This commit is contained in:
2026-07-08 15:45:16 -04:00
commit a7964f9410
89 changed files with 25924 additions and 0 deletions

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

507
dbutil/automapper_test.go Normal file
View File

@@ -0,0 +1,507 @@
package dbutil
import (
"reflect"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/google/uuid"
)
// buildMapping (flat struct)
func TestBuildMappingFlat(t *testing.T) {
tm := getMapping(reflect.TypeOf(testUser{}))
want := map[string]bool{
"id": true, "username": true, "email": true, "password": true,
"first_name": true, "last_name": true,
"login_count": true, "created": true, "active": true,
}
for col := range want {
if _, ok := tm.columns[col]; !ok {
t.Errorf("missing mapping for column %q", col)
}
}
if len(tm.columns) != len(want) {
t.Errorf("column count = %d, want %d", len(tm.columns), len(want))
}
}
// buildMapping (nested struct / DTO)
func TestBuildMappingNested(t *testing.T) {
type DTO struct {
Membership testMembership
User testUser
}
tm := getMapping(reflect.TypeOf(DTO{}))
if _, ok := tm.columns["membership.id"]; !ok {
t.Error("missing membership.id")
}
if _, ok := tm.columns["membership.user_id"]; !ok {
t.Error("missing membership.user_id")
}
if _, ok := tm.columns["user.id"]; !ok {
t.Error("missing user.id")
}
if _, ok := tm.columns["user.username"]; !ok {
t.Error("missing user.username")
}
// No unprefixed columns should exist
if _, ok := tm.columns["id"]; ok {
t.Error("should not have unprefixed 'id'")
}
}
// buildMapping (alias tag)
func TestBuildMappingAlias(t *testing.T) {
type DTO struct {
User testUser
CreatedBy testUser `alias:"created_by"`
}
tm := getMapping(reflect.TypeOf(DTO{}))
if _, ok := tm.columns["user.id"]; !ok {
t.Error("missing user.id")
}
if _, ok := tm.columns["created_by.id"]; !ok {
t.Error("missing created_by.id")
}
if _, ok := tm.columns["created_by.username"]; !ok {
t.Error("missing created_by.username")
}
}
// buildMapping (pointer-to-struct for LEFT JOINs)
func TestBuildMappingPointerStruct(t *testing.T) {
type DTO struct {
Session testSession
User *testUser `alias:"test_user"`
}
tm := getMapping(reflect.TypeOf(DTO{}))
if _, ok := tm.columns["session.id"]; !ok {
t.Error("missing session.id")
}
if _, ok := tm.columns["test_user.id"]; !ok {
t.Error("missing test_user.id (via alias tag)")
}
if len(tm.ptrStructs) != 1 {
t.Errorf("ptrStructs len = %d, want 1", len(tm.ptrStructs))
}
}
// buildMapping (embedded struct)
func TestBuildMappingEmbedded(t *testing.T) {
type Base struct {
ID uuid.UUID `db:"id"`
Created time.Time `db:"created"`
}
type Extended struct {
Base
Name string `db:"name"`
}
tm := getMapping(reflect.TypeOf(Extended{}))
if _, ok := tm.columns["id"]; !ok {
t.Error("missing flattened id from embedded Base")
}
if _, ok := tm.columns["created"]; !ok {
t.Error("missing flattened created from embedded Base")
}
if _, ok := tm.columns["name"]; !ok {
t.Error("missing name")
}
}
// isModelStruct
func TestIsModelStruct(t *testing.T) {
if !isModelStruct(reflect.TypeOf(testUser{})) {
t.Error("testUser should be a model struct")
}
if isModelStruct(reflect.TypeOf(time.Time{})) {
t.Error("time.Time should not be a model struct")
}
if isModelStruct(reflect.TypeOf(struct{ X int }{})) {
t.Error("anonymous struct without db tags should not be a model struct")
}
}
// DebugMapping
func TestDebugMapping(t *testing.T) {
type DTO struct {
User testUser
}
m := DebugMapping(DTO{})
if path, ok := m["user.id"]; !ok || path != "User.ID" {
t.Errorf("user.id mapping = %q, ok = %v", path, ok)
}
if path, ok := m["user.username"]; !ok || path != "User.Username" {
t.Errorf("user.username mapping = %q, ok = %v", path, ok)
}
}
// ScanOne (flat struct via sqlmock)
func TestScanOneFlat(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
id := uuid.New()
now := time.Now().Truncate(time.Second)
rows := sqlmock.NewRows([]string{"id", "username", "email", "password", "first_name", "last_name", "login_count", "created", "active"}).
AddRow(id, "alice", "alice@test.com", "hash", "Alice", "Smith", int32(5), now, true)
mock.ExpectQuery("SELECT").WillReturnRows(rows)
sqlRows, err := db.Query("SELECT anything")
if err != nil {
t.Fatal(err)
}
defer sqlRows.Close()
var user testUser
err = ScanOne(sqlRows, &user)
if err != nil {
t.Fatal(err)
}
if user.ID != id {
t.Errorf("ID = %v, want %v", user.ID, id)
}
if user.Username != "alice" {
t.Errorf("Username = %q", user.Username)
}
if user.Email != "alice@test.com" {
t.Errorf("Email = %q", user.Email)
}
if user.FirstName != "Alice" {
t.Errorf("FirstName = %q", user.FirstName)
}
if user.LoginCount != 5 {
t.Errorf("LoginCount = %d", user.LoginCount)
}
if user.Active != true {
t.Errorf("Active = %v", user.Active)
}
}
// ScanAll (multiple rows)
func TestScanAllFlat(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
id1, id2 := uuid.New(), uuid.New()
now := time.Now().Truncate(time.Second)
rows := sqlmock.NewRows([]string{"id", "key", "user_id", "org_id", "created", "user_agent", "revoked"}).
AddRow(id1, "key1", uuid.New(), nil, now, "Mozilla", false).
AddRow(id2, "key2", uuid.New(), nil, now, "Chrome", true)
mock.ExpectQuery("SELECT").WillReturnRows(rows)
sqlRows, err := db.Query("SELECT anything")
if err != nil {
t.Fatal(err)
}
defer sqlRows.Close()
var sessions []testSession
err = ScanAll(sqlRows, &sessions)
if err != nil {
t.Fatal(err)
}
if len(sessions) != 2 {
t.Fatalf("len = %d, want 2", len(sessions))
}
if sessions[0].Key != "key1" {
t.Errorf("[0].Key = %q", sessions[0].Key)
}
if sessions[1].Revoked != true {
t.Errorf("[1].Revoked = %v", sessions[1].Revoked)
}
}
// ScanOne (nested DTO with prefixed columns)
func TestScanOneNested(t *testing.T) {
type DTO struct {
Membership testMembership
User testUser
}
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mID, uID, orgID := uuid.New(), uuid.New(), uuid.New()
now := time.Now().Truncate(time.Second)
cols := []string{
"membership.id", "membership.user_id", "membership.org_id",
"membership.created_by", "membership.joined",
"membership.login_count",
"user.id", "user.username", "user.email",
}
rows := sqlmock.NewRows(cols).
AddRow(
mID, uID, orgID,
uuid.New(), now,
int32(10),
uID, "alice", "alice@test.com",
)
mock.ExpectQuery("SELECT").WillReturnRows(rows)
sqlRows, err := db.Query("SELECT anything")
if err != nil {
t.Fatal(err)
}
defer sqlRows.Close()
var dto DTO
err = ScanOne(sqlRows, &dto)
if err != nil {
t.Fatal(err)
}
if dto.Membership.ID != mID {
t.Errorf("Membership.ID = %v, want %v", dto.Membership.ID, mID)
}
if dto.Membership.OrgID != orgID {
t.Errorf("Membership.OrgID = %v, want %v", dto.Membership.OrgID, orgID)
}
if dto.User.Username != "alice" {
t.Errorf("User.Username = %q", dto.User.Username)
}
if dto.Membership.LoginCount != 10 {
t.Errorf("Membership.LoginCount = %v, want 10", dto.Membership.LoginCount)
}
}
// ScanOne (alias tag)
func TestScanOneAlias(t *testing.T) {
type DTO struct {
User testUser
CreatedBy testUser `alias:"created_by"`
}
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
userID, cbID := uuid.New(), uuid.New()
cols := []string{"user.id", "user.username", "created_by.id", "created_by.username"}
rows := sqlmock.NewRows(cols).
AddRow(userID, "alice", cbID, "bob")
mock.ExpectQuery("SELECT").WillReturnRows(rows)
sqlRows, err := db.Query("SELECT anything")
if err != nil {
t.Fatal(err)
}
defer sqlRows.Close()
var dto DTO
err = ScanOne(sqlRows, &dto)
if err != nil {
t.Fatal(err)
}
if dto.User.ID != userID {
t.Errorf("User.ID = %v, want %v", dto.User.ID, userID)
}
if dto.User.Username != "alice" {
t.Errorf("User.Username = %q", dto.User.Username)
}
if dto.CreatedBy.ID != cbID {
t.Errorf("CreatedBy.ID = %v, want %v", dto.CreatedBy.ID, cbID)
}
if dto.CreatedBy.Username != "bob" {
t.Errorf("CreatedBy.Username = %q", dto.CreatedBy.Username)
}
}
// Pointer-to-struct nil detection (LEFT JOIN)
func TestScanOnePtrStructNilDetection(t *testing.T) {
type DTO struct {
Session testSession
User *testUser `alias:"test_user"`
}
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
sessID := uuid.New()
now := time.Now().Truncate(time.Second)
cols := []string{
"session.id", "session.key", "session.user_id", "session.org_id",
"session.created", "session.user_agent", "session.revoked",
// All user columns are NULL (LEFT JOIN miss)
"test_user.id", "test_user.username",
}
rows := sqlmock.NewRows(cols).
AddRow(
sessID, "key1", uuid.New(), nil,
now, "Mozilla", false,
// NULL user
uuid.Nil, "",
)
mock.ExpectQuery("SELECT").WillReturnRows(rows)
sqlRows, err := db.Query("SELECT anything")
if err != nil {
t.Fatal(err)
}
defer sqlRows.Close()
var dto DTO
err = ScanOne(sqlRows, &dto)
if err != nil {
t.Fatal(err)
}
if dto.Session.ID != sessID {
t.Errorf("Session.ID = %v, want %v", dto.Session.ID, sessID)
}
if dto.User != nil {
t.Errorf("User should be nil for LEFT JOIN miss, got %+v", dto.User)
}
}
func TestScanOnePtrStructNonNil(t *testing.T) {
type DTO struct {
Session testSession
User *testUser `alias:"test_user"`
}
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
sessID, userID := uuid.New(), uuid.New()
now := time.Now().Truncate(time.Second)
cols := []string{
"session.id", "session.key", "session.user_id", "session.org_id",
"session.created", "session.user_agent", "session.revoked",
"test_user.id", "test_user.username",
}
rows := sqlmock.NewRows(cols).
AddRow(
sessID, "key1", userID, nil,
now, "Mozilla", false,
userID, "alice",
)
mock.ExpectQuery("SELECT").WillReturnRows(rows)
sqlRows, err := db.Query("SELECT anything")
if err != nil {
t.Fatal(err)
}
defer sqlRows.Close()
var dto DTO
err = ScanOne(sqlRows, &dto)
if err != nil {
t.Fatal(err)
}
if dto.User == nil {
t.Fatal("User should not be nil")
}
if dto.User.ID != userID {
t.Errorf("User.ID = %v, want %v", dto.User.ID, userID)
}
if dto.User.Username != "alice" {
t.Errorf("User.Username = %q", dto.User.Username)
}
}
// ScanOne: sql.ErrNoRows
func TestScanOneNoRows(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
rows := sqlmock.NewRows([]string{"id", "username"})
mock.ExpectQuery("SELECT").WillReturnRows(rows)
sqlRows, err := db.Query("SELECT anything")
if err != nil {
t.Fatal(err)
}
defer sqlRows.Close()
type Small struct {
ID string `db:"id"`
Username string `db:"username"`
}
var s Small
err = ScanOne(sqlRows, &s)
if err == nil {
t.Error("expected error for no rows")
}
}
// Columns function
func TestColumnsFunction(t *testing.T) {
result := Columns(testSession{}, "s")
if !containsSubstr(result, `s.id AS "test_session.id"`) {
t.Errorf("missing s.id alias: %s", result)
}
if !containsSubstr(result, `s.key AS "test_session.key"`) {
t.Errorf("missing s.key alias: %s", result)
}
}
func TestColumnsWithPrefix(t *testing.T) {
result := Columns(testUser{}, "cb", "created_by")
if !containsSubstr(result, `cb.id AS "created_by.id"`) {
t.Errorf("missing custom prefix alias: %s", result)
}
}

1270
dbutil/builder.go Normal file

File diff suppressed because it is too large Load Diff

737
dbutil/builder_test.go Normal file
View File

@@ -0,0 +1,737 @@
package dbutil
import (
"testing"
"time"
"github.com/google/uuid"
)
// toSnakeCase
func TestToSnakeCase(t *testing.T) {
cases := []struct{ in, want string }{
{"AppUser", "app_user"},
{"OrgUser", "org_user"},
{"ID", "id"},
{"IPAddr", "ip_addr"},
{"OrgUserDTO", "org_user_dto"},
{"CreatedBy", "created_by"},
{"EmailNotificationECd", "email_notification_e_cd"},
{"HTMLParser", "html_parser"},
{"Simple", "simple"},
}
for _, tc := range cases {
got := toSnakeCase(tc.in)
if got != tc.want {
t.Errorf("toSnakeCase(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// TableRef
func TestTableRef(t *testing.T) {
u := T[testUser]("u")
if u.ref() != "u" {
t.Errorf("ref() = %q, want %q", u.ref(), "u")
}
if u.fromExpr() != "test_user u" {
t.Errorf("fromExpr() = %q, want %q", u.fromExpr(), "test_user u")
}
noAlias := T[testUser]()
if noAlias.ref() != "test_user" {
t.Errorf("ref() without alias = %q, want %q", noAlias.ref(), "test_user")
}
if noAlias.fromExpr() != "test_user" {
t.Errorf("fromExpr() without alias = %q, want %q", noAlias.fromExpr(), "test_user")
}
}
func TestTableRefPanicsOnUnregistered(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("expected panic for unregistered type")
}
}()
type Bogus struct {
X string `db:"x"`
}
T[Bogus]()
}
func TestTName(t *testing.T) {
ref := TName("my_table", "mt")
if ref.fromExpr() != "my_table mt" {
t.Errorf("fromExpr() = %q, want %q", ref.fromExpr(), "my_table mt")
}
}
// Cols / ColsFlat / AllColNames
func TestColsFlat(t *testing.T) {
s := T[testSession]("s")
flat := s.ColsFlat()
if !containsSubstr(flat, "s.id") {
t.Errorf("ColsFlat missing s.id: %s", flat)
}
if containsSubstr(flat, " AS ") {
t.Errorf("ColsFlat should not contain AS aliases: %s", flat)
}
}
func TestCols(t *testing.T) {
u := T[testUser]("u")
cols := u.Cols()
if !containsSubstr(cols, `u.id AS "test_user.id"`) {
t.Errorf("Cols missing aliased id: %s", cols)
}
if !containsSubstr(cols, `u.username AS "test_user.username"`) {
t.Errorf("Cols missing aliased username: %s", cols)
}
}
func TestColsMapAs(t *testing.T) {
cb := T[testUser]("cb").MapAs("created_by")
cols := cb.Cols()
if !containsSubstr(cols, `cb.id AS "created_by.id"`) {
t.Errorf("MapAs Cols missing aliased id: %s", cols)
}
}
func TestAllColNames(t *testing.T) {
s := T[testSession]()
names := s.AllColNames()
want := []string{"id", "key", "user_id", "org_id", "created", "user_agent", "revoked"}
if len(names) != len(want) {
t.Fatalf("AllColNames len = %d, want %d\ngot: %v\nwant: %v", len(names), len(want), names, want)
}
for i, n := range names {
if n != want[i] {
t.Errorf("AllColNames[%d] = %q, want %q", i, n, want[i])
}
}
}
// Col conditions
func TestColConditions(t *testing.T) {
u := T[testUser]("u")
c := u.F(&u.M.ID)
id1 := uuid.New()
id2 := uuid.New()
cases := []struct {
name string
cond Cond
frag string
argc int
}{
{"Eq", c.Eq(id1), "u.id = ?", 1},
{"Neq", c.Neq(id1), "u.id <> ?", 1},
{"Gt", c.Gt(id1), "u.id > ?", 1},
{"GtEq", c.GtEq(id1), "u.id >= ?", 1},
{"Lt", c.Lt(id1), "u.id < ?", 1},
{"LtEq", c.LtEq(id1), "u.id <= ?", 1},
{"Like", c.Like("%x%"), "u.id LIKE ?", 1},
{"IsNull", c.IsNull(), "u.id IS NULL", 0},
{"IsNotNull", c.IsNotNull(), "u.id IS NOT NULL", 0},
{"Between", c.Between(id1, id2), "u.id BETWEEN ? AND ?", 2},
{"EqCol", c.EqCol(u.C("other")), "u.id = u.other", 0},
}
for _, tc := range cases {
if tc.cond.fragment != tc.frag {
t.Errorf("%s: fragment = %q, want %q", tc.name, tc.cond.fragment, tc.frag)
}
if len(tc.cond.args) != tc.argc {
t.Errorf("%s: args len = %d, want %d", tc.name, len(tc.cond.args), tc.argc)
}
}
}
func TestColIn(t *testing.T) {
u := T[testUser]("u")
id1, id2, id3 := uuid.New(), uuid.New(), uuid.New()
// Variadic
c := u.F(&u.M.ID).In(id1, id2, id3)
if c.fragment != "u.id IN (?, ?, ?)" {
t.Errorf("In variadic fragment = %q", c.fragment)
}
if len(c.args) != 3 {
t.Errorf("In variadic args len = %d", len(c.args))
}
// Slice expansion
ids := []uuid.UUID{uuid.New(), uuid.New()}
c2 := u.F(&u.M.ID).In(ids)
if c2.fragment != "u.id IN (?, ?)" {
t.Errorf("In slice fragment = %q", c2.fragment)
}
if len(c2.args) != 2 {
t.Errorf("In slice args len = %d", len(c2.args))
}
}
func TestLower(t *testing.T) {
u := T[testUser]("u")
c := Lower(u.F(&u.M.Username))
if c.expr != "LOWER(u.username)" {
t.Errorf("Lower expr = %q", c.expr)
}
}
// Cond composition
func TestCondAndOr(t *testing.T) {
a := Cond{fragment: "a = ?", args: []any{1}}
b := Cond{fragment: "b = ?", args: []any{2}}
and := a.And(b)
if and.fragment != "(a = ? AND b = ?)" {
t.Errorf("And fragment = %q", and.fragment)
}
if len(and.args) != 2 {
t.Errorf("And args len = %d", len(and.args))
}
or := a.Or(b)
if or.fragment != "(a = ? OR b = ?)" {
t.Errorf("Or fragment = %q", or.fragment)
}
}
func TestCondIdentity(t *testing.T) {
empty := Cond{}
real := Cond{fragment: "x = ?", args: []any{1}}
if empty.And(real).fragment != real.fragment {
t.Error("empty.And(real) should return real")
}
if real.And(empty).fragment != real.fragment {
t.Error("real.And(empty) should return real")
}
}
func TestCondNot(t *testing.T) {
c := Cond{fragment: "a = ?", args: []any{1}}
n := c.Not()
if n.fragment != "NOT (a = ?)" {
t.Errorf("Not fragment = %q", n.fragment)
}
}
// SELECT Build
func TestSelectSimple(t *testing.T) {
u := T[testUser]("u")
id := uuid.New()
sql, args := Select(u.ColsFlat()).
From(u).
Where(u.F(&u.M.ID).Eq(id)).
Build()
if !containsSubstr(sql, "SELECT u.id") {
t.Errorf("missing columns: %s", sql)
}
if !containsSubstr(sql, "FROM test_user u") {
t.Errorf("missing FROM: %s", sql)
}
if !containsSubstr(sql, "WHERE u.id = $1") {
t.Errorf("missing WHERE with $1: %s", sql)
}
if len(args) != 1 || args[0] != id {
t.Errorf("args = %v", args)
}
}
func TestSelectJoin(t *testing.T) {
m := T[testMembership]("m")
u := T[testUser]("u")
orgID := uuid.New()
sql, args := Select(m.Cols(), u.Cols()).
From(m).
InnerJoin(u, u.F(&u.M.ID).EqCol(m.F(&m.M.UserID))).
Where(m.F(&m.M.OrgID).Eq(orgID)).
OrderBy(u.F(&u.M.LastName).Asc()).
Limit(25).
Offset(50).
Build()
if !containsSubstr(sql, "INNER JOIN test_user u ON u.id = m.user_id") {
t.Errorf("missing JOIN: %s", sql)
}
if !containsSubstr(sql, "WHERE m.org_id = $1") {
t.Errorf("missing WHERE: %s", sql)
}
if !containsSubstr(sql, "ORDER BY u.last_name ASC") {
t.Errorf("missing ORDER BY: %s", sql)
}
if !containsSubstr(sql, "LIMIT 25") {
t.Errorf("missing LIMIT: %s", sql)
}
if !containsSubstr(sql, "OFFSET 50") {
t.Errorf("missing OFFSET: %s", sql)
}
if len(args) != 1 {
t.Errorf("args = %v", args)
}
}
func TestSelectLeftJoin(t *testing.T) {
s := T[testSession]("s")
u := T[testUser]("u")
sql, _ := Select(s.Cols(), u.Cols()).
From(s).
LeftJoin(u, u.F(&u.M.ID).EqCol(s.F(&s.M.UserID))).
Build()
if !containsSubstr(sql, "LEFT JOIN test_user u ON u.id = s.user_id") {
t.Errorf("missing LEFT JOIN: %s", sql)
}
}
func TestSelectCount(t *testing.T) {
m := T[testMembership]("m")
orgID := uuid.New()
sql, args := Select("COUNT(*)").
From(m).
Where(m.F(&m.M.OrgID).Eq(orgID)).
Build()
if sql != "SELECT COUNT(*) FROM test_membership m WHERE m.org_id = $1" {
t.Errorf("sql = %q", sql)
}
if len(args) != 1 {
t.Errorf("args len = %d", len(args))
}
}
func TestSelectMultipleWhereParams(t *testing.T) {
u := T[testUser]("u")
now := time.Now()
sql, args := Select(u.ColsFlat()).
From(u).
Where(
u.F(&u.M.LoginCount).Gt(int32(5)).
And(u.F(&u.M.Created).GtEq(now)).
And(u.F(&u.M.Username).Like("%admin%")),
).
Build()
if !containsSubstr(sql, "$1") && !containsSubstr(sql, "$2") && !containsSubstr(sql, "$3") {
t.Errorf("missing param placeholders: %s", sql)
}
if len(args) != 3 {
t.Errorf("args len = %d, want 3", len(args))
}
}
func TestSelectSubquery(t *testing.T) {
s := T[testSession]("s")
sub := Select(s.F(&s.M.ID).String()).
From(s).
Where(s.F(&s.M.UserID).Eq(uuid.New()).And(s.F(&s.M.Revoked).Eq(false))).
OrderBy(s.F(&s.M.Created).Asc()).
Limit(3)
s2 := T[testSession]()
sql, args := Update(s2).
Set("revoked", true).
Where(s2.F(&s2.M.ID).InQuery(sub)).
Build()
if !containsSubstr(sql, "IN (SELECT s.id FROM test_session s WHERE") {
t.Errorf("missing subquery: %s", sql)
}
if len(args) != 3 {
t.Errorf("args len = %d, want 3, args = %v", len(args), args)
}
if !containsSubstr(sql, "$1") || !containsSubstr(sql, "$2") || !containsSubstr(sql, "$3") {
t.Errorf("params not sequential: %s", sql)
}
}
func TestSelectGroupBy(t *testing.T) {
u := T[testUser]("u")
sql, _ := Select("u.active", "COUNT(*)").
From(u).
GroupBy("u.active").
Build()
if !containsSubstr(sql, "GROUP BY u.active") {
t.Errorf("missing GROUP BY: %s", sql)
}
}
func TestAndWhere(t *testing.T) {
u := T[testUser]("u")
q := Select(u.ColsFlat()).From(u)
q.AndWhere(u.F(&u.M.ID).Eq(uuid.New()))
q.AndWhere(u.F(&u.M.Username).Eq("bob"))
sql, args := q.Build()
if !containsSubstr(sql, "$1") || !containsSubstr(sql, "$2") {
t.Errorf("missing params: %s", sql)
}
if len(args) != 2 {
t.Errorf("args len = %d", len(args))
}
}
// INSERT Build
func TestInsertValues(t *testing.T) {
u := T[testUser]()
id := uuid.New()
sql, args := InsertInto(u).
Columns(u.FieldNames(&u.M.ID, &u.M.Username, &u.M.Email)...).
Values(id, "alice", "alice@example.com").
Build()
if sql != "INSERT INTO test_user (id, username, email) VALUES ($1, $2, $3)" {
t.Errorf("sql = %q", sql)
}
if len(args) != 3 {
t.Errorf("args len = %d", len(args))
}
if args[1] != "alice" {
t.Errorf("args[1] = %v", args[1])
}
}
func TestInsertModel(t *testing.T) {
s := T[testSession]()
sess := testSession{
Key: "sess_abc",
UserID: uuid.New(),
}
sql, args := InsertInto(s).
Columns(s.FieldNames(&s.M.Key, &s.M.UserID)...).
Model(sess).
Build()
if sql != "INSERT INTO test_session (key, user_id) VALUES ($1, $2)" {
t.Errorf("sql = %q", sql)
}
if args[0] != "sess_abc" {
t.Errorf("args[0] = %v", args[0])
}
if args[1] != sess.UserID {
t.Errorf("args[1] = %v", args[1])
}
}
func TestInsertModelAllColumns(t *testing.T) {
s := T[testSession]()
sess := testSession{Key: "k"}
sql, args := InsertInto(s).Model(sess).Build()
if !containsSubstr(sql, "INSERT INTO test_session (id, key, user_id") {
t.Errorf("sql = %q", sql)
}
if len(args) != 7 { // testSession has 7 fields
t.Errorf("args len = %d, want 7", len(args))
}
}
// UPDATE Build
func TestUpdateSet(t *testing.T) {
u := T[testUser]()
id := uuid.New()
sql, args := Update(u).
Set("login_count", 42).
Set("active", false).
Where(u.F(&u.M.ID).Eq(id)).
Build()
if sql != "UPDATE test_user SET login_count = $1, active = $2 WHERE test_user.id = $3" {
t.Errorf("sql = %q", sql)
}
if len(args) != 3 {
t.Errorf("args len = %d", len(args))
}
if args[0] != 42 {
t.Errorf("args[0] = %v", args[0])
}
}
func TestUpdateModelSetColumns(t *testing.T) {
u := T[testUser]()
user := testUser{
ID: uuid.New(),
FirstName: "Alice",
LastName: "Smith",
Email: "alice@test.com",
}
sql, args := Update(u).
SetColumns(u.FieldNames(&u.M.FirstName, &u.M.LastName, &u.M.Email)...).
Model(user).
Where(u.F(&u.M.ID).Eq(user.ID)).
Build()
if !containsSubstr(sql, "SET first_name = $1, last_name = $2, email = $3") {
t.Errorf("missing SET: %s", sql)
}
if !containsSubstr(sql, "WHERE test_user.id = $4") {
t.Errorf("missing WHERE: %s", sql)
}
if args[0] != "Alice" || args[1] != "Smith" || args[2] != "alice@test.com" {
t.Errorf("args = %v", args)
}
}
// DELETE Build
func TestDeleteSimple(t *testing.T) {
s := T[testSession]()
sql, args := DeleteFrom(s).
Where(s.F(&s.M.Key).Eq("sess_xyz")).
Build()
if sql != "DELETE FROM test_session WHERE test_session.key = $1" {
t.Errorf("sql = %q", sql)
}
if len(args) != 1 || args[0] != "sess_xyz" {
t.Errorf("args = %v", args)
}
}
func TestDeleteNoWhere(t *testing.T) {
s := T[testSession]()
sql, args := DeleteFrom(s).Build()
if sql != "DELETE FROM test_session" {
t.Errorf("sql = %q", sql)
}
if len(args) != 0 {
t.Errorf("args = %v", args)
}
}
// replaceParams
func TestReplaceParams(t *testing.T) {
cases := []struct{ in, want string }{
{"x = ?", "x = $1"},
{"a = ? AND b = ?", "a = $1 AND b = $2"},
{"IN (?, ?, ?)", "IN ($1, $2, $3)"},
{"no params", "no params"},
}
for _, tc := range cases {
got := replaceParams(tc.in)
if got != tc.want {
t.Errorf("replaceParams(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// extractModelValues
func TestExtractModelValues(t *testing.T) {
user := testUser{
Username: "bob",
Email: "bob@test.com",
FirstName: "Bob",
}
vals := extractModelValues(user, []string{"username", "email", "first_name"})
if vals[0] != "bob" || vals[1] != "bob@test.com" || vals[2] != "Bob" {
t.Errorf("vals = %v", vals)
}
}
func TestExtractModelValuesMissing(t *testing.T) {
user := testUser{Username: "bob"}
vals := extractModelValues(user, []string{"username", "nonexistent"})
if vals[0] != "bob" {
t.Errorf("vals[0] = %v", vals[0])
}
if vals[1] != nil {
t.Errorf("vals[1] for missing column = %v, want nil", vals[1])
}
}
// Field references
func TestFieldReference(t *testing.T) {
u := T[testUser]("u")
col := u.F(&u.M.ID)
if col.String() != "u.id" {
t.Errorf("F(&u.M.ID) = %q, want %q", col.String(), "u.id")
}
col2 := u.F(&u.M.FirstName)
if col2.String() != "u.first_name" {
t.Errorf("F(&u.M.FirstName) = %q, want %q", col2.String(), "u.first_name")
}
col3 := u.F(&u.M.Email)
if col3.String() != "u.email" {
t.Errorf("F(&u.M.Email) = %q, want %q", col3.String(), "u.email")
}
}
func TestFieldNames(t *testing.T) {
u := T[testUser]()
names := u.FieldNames(&u.M.FirstName, &u.M.LastName, &u.M.Email)
want := []string{"first_name", "last_name", "email"}
if len(names) != len(want) {
t.Fatalf("FieldNames len = %d, want %d", len(names), len(want))
}
for i, n := range names {
if n != want[i] {
t.Errorf("FieldNames[%d] = %q, want %q", i, n, want[i])
}
}
}
func TestFieldReferenceEqCol(t *testing.T) {
u := T[testUser]("u")
m := T[testMembership]("m")
cond := u.F(&u.M.ID).EqCol(m.F(&m.M.UserID))
if cond.fragment != "u.id = m.user_id" {
t.Errorf("EqCol fragment = %q", cond.fragment)
}
}
func TestFieldReferenceInSelect(t *testing.T) {
u := T[testUser]("u")
sql, args := Select(u.F(&u.M.ID).String(), u.F(&u.M.Username).String()).
From(u).
Where(u.F(&u.M.Email).Eq("test@test.com")).
Build()
if sql != "SELECT u.id, u.username FROM test_user u WHERE u.email = $1" {
t.Errorf("sql = %q", sql)
}
if len(args) != 1 || args[0] != "test@test.com" {
t.Errorf("args = %v", args)
}
}
func TestFieldNamesWithSetColumns(t *testing.T) {
u := T[testUser]()
user := testUser{
ID: uuid.New(),
FirstName: "Test",
LastName: "User",
}
sql, args := Update(u).
SetColumns(u.FieldNames(&u.M.FirstName, &u.M.LastName)...).
Model(user).
Where(u.F(&u.M.ID).Eq(user.ID)).
Build()
if !containsSubstr(sql, "SET first_name = $1, last_name = $2") {
t.Errorf("missing SET: %s", sql)
}
if !containsSubstr(sql, "WHERE test_user.id = $3") {
t.Errorf("missing WHERE: %s", sql)
}
if args[0] != "Test" || args[1] != "User" {
t.Errorf("args = %v", args)
}
}
func TestFieldReferencePanicsOnBadPointer(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("expected panic for bad field pointer")
}
}()
u := T[testUser]("u")
var unrelated int
u.F(&unrelated)
}
func TestTableGenericAs(t *testing.T) {
u := T[testUser]("u")
u2 := u.As("u2")
if u2.ref() != "u2" {
t.Errorf("As ref() = %q, want %q", u2.ref(), "u2")
}
col := u2.F(&u2.M.ID)
if col.String() != "u2.id" {
t.Errorf("F after As = %q, want %q", col.String(), "u2.id")
}
}
func TestTableGenericMapAs(t *testing.T) {
cb := T[testUser]("cb").MapAs("created_by")
cols := cb.Cols()
if !containsSubstr(cols, `cb.id AS "created_by.id"`) {
t.Errorf("MapAs Cols missing aliased id: %s", cols)
}
col := cb.F(&cb.M.ID)
if col.String() != "cb.id" {
t.Errorf("F after MapAs = %q, want %q", col.String(), "cb.id")
}
}
// checkType
func TestCheckTypePanicsOnMismatch(t *testing.T) {
u := T[testUser]("u")
defer func() {
r := recover()
if r == nil {
t.Fatal("expected panic for type mismatch")
}
msg, ok := r.(string)
if !ok {
t.Fatalf("panic value is not string: %v", r)
}
if !containsSubstr(msg, "type mismatch") {
t.Errorf("panic message = %q, want it to contain 'type mismatch'", msg)
}
}()
// Active is bool, passing string should panic
u.F(&u.M.Active).Eq("true")
}
func TestCheckTypeSkipsForRawCol(t *testing.T) {
u := T[testUser]("u")
// C() returns a Col without fieldType — should not panic
u.C("active").Eq("anything")
}
func TestCheckTypeSkipsForNilVal(t *testing.T) {
u := T[testUser]("u")
// nil should not panic even on typed columns
u.F(&u.M.Active).Eq(nil)
}
// helpers
func containsSubstr(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsIdx(s, sub))
}
func containsIdx(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

64
dbutil/db_connect.go Normal file
View File

@@ -0,0 +1,64 @@
package dbutil
import (
"database/sql"
"fmt"
"log"
"time"
_ "github.com/lib/pq"
)
// The dbutil package provides an interface between go code and a relational database.
var db *sql.DB
// ConnConfig holds everything Init needs to open the Postgres connection pool.
// The app builds it from its own config (dbutil.ConnConfig{Username: cfg.X, ...})
// so the framework never imports application config.
type ConnConfig struct {
Username string
Password string
Host string
Port int
Name string
Schema string
SSLMode string
MaxConns int
TimeoutSeconds int
}
// BuildConnectionString renders a lib/pq Postgres DSN with the session TimeZone
// pinned to UTC. Exported so cmd/migrate reuses it instead of duplicating the
// format string.
func BuildConnectionString(c ConnConfig) string {
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?search_path=%s&sslmode=%s&options=-c%%20TimeZone%%3DUTC",
c.Username,
c.Password,
c.Host,
c.Port,
c.Name,
c.Schema,
c.SSLMode,
)
}
// Init opens the global connection pool from c and pings it. It fatals on
// failure, matching the previous package behaviour.
func Init(c ConnConfig) {
var err error
db, err = sql.Open("postgres", BuildConnectionString(c))
if err != nil {
log.Fatal(err.Error())
}
if pingErr := db.Ping(); pingErr != nil {
log.Fatal(pingErr.Error())
}
db.SetMaxOpenConns(c.MaxConns)
db.SetMaxIdleConns(2)
db.SetConnMaxIdleTime(time.Duration(c.TimeoutSeconds) * time.Second)
}
func DB() *sql.DB { return db }

306
dbutil/filters.go Normal file
View File

@@ -0,0 +1,306 @@
package dbutil
import (
. "kjol/basic"
"net/http"
"strconv"
"strings"
)
const (
PAGE_NUM_KEY = "page_num"
ORDER_BY_KEY = "order_by"
ITEMS_PER_PAGE_KEY = "items_per_page"
SEARCH_KEY_PREFIX = "search_"
FILTER_DEFAULT_MAX_ITEMS = 25
)
type Search struct {
Values []string
Identifier string
CaseSensitive bool
IgnoreWhitespace bool
}
type OrderBy struct {
Identifier string
Descending bool
}
type Pagination struct {
Disabled bool
CurrentPage int
NextPage int
PreviousPage int
TotalPages int
TotalItems int
MaxItemsPerPage int
ItemsThisPage int
ViewRangeLower int
ViewRangeUpper int
}
type Filter struct {
Search []Search
Pagination Pagination
OrderBy OrderBy
}
// GetSearch returns the Search struct for the given identifier, or nil if not found.
func (f *Filter) GetSearch(identifier string) *Search {
for i := range f.Search {
if f.Search[i].Identifier == identifier {
return &f.Search[i]
}
}
return nil
}
// BindOrderBy applies an ORDER BY clause to the select builder if the filter's
// OrderBy identifier matches.
func BindOrderBy(identifier string, col Col, f Filter, sb *SelectBuilder) {
if sb == nil {
return
}
if identifier == f.OrderBy.Identifier {
if f.OrderBy.Descending {
sb.OrderBy(col.Desc())
} else {
sb.OrderBy(col.Asc())
}
}
}
// BindOrderByMultiCols applies ORDER BY with multiple columns if the filter's
// OrderBy identifier matches.
func BindOrderByMultiCols(identifier string, f Filter, sb *SelectBuilder, cols ...Col) {
if sb == nil {
return
}
if identifier == f.OrderBy.Identifier {
exprs := make([]OrderExpr, len(cols))
for i, c := range cols {
if f.OrderBy.Descending {
exprs[i] = c.Desc()
} else {
exprs[i] = c.Asc()
}
}
sb.OrderBy(exprs...)
}
}
// SetDefaultOrderBy applies the given order if no ORDER BY has been set yet.
func SetDefaultOrderBy(defaultExpr OrderExpr, sb *SelectBuilder) {
if sb == nil {
return
}
if !sb.HasOrderBy() {
sb.OrderBy(defaultExpr)
}
}
// ApplyPagination applies LIMIT and OFFSET to the select builder based on the filter.
func ApplyPagination(f Filter, sb *SelectBuilder) {
if !f.Pagination.Disabled {
if f.Pagination.MaxItemsPerPage > 0 {
sb.Limit(int64(f.Pagination.MaxItemsPerPage))
sb.Offset(int64((f.Pagination.CurrentPage - 1) * f.Pagination.MaxItemsPerPage))
}
}
}
func ParseFilterFromRequest(r *http.Request) Filter {
if r.Body != nil {
defer r.Body.Close()
}
r.ParseForm()
filter := Filter{}
filter.Pagination.MaxItemsPerPage = FILTER_DEFAULT_MAX_ITEMS
filter.Pagination.CurrentPage = 1
// Parse pagination
if pageNum := r.FormValue(PAGE_NUM_KEY); pageNum != "" {
if n, err := strconv.Atoi(pageNum); err == nil && n > 0 {
filter.Pagination.CurrentPage = n
}
}
if itemsPerPage := r.FormValue(ITEMS_PER_PAGE_KEY); itemsPerPage != "" {
if n, err := strconv.Atoi(itemsPerPage); err == nil {
if n > 0 {
filter.Pagination.MaxItemsPerPage = n
} else if n == -1 {
filter.Pagination.MaxItemsPerPage = -1
}
}
}
// Parse order by
if orderByValue := r.FormValue(ORDER_BY_KEY); orderByValue != "" {
filter.OrderBy.Identifier = orderByValue
filter.OrderBy.Descending = r.FormValue("order_desc") == "true"
}
// Parse search parameters (keys prefixed with search_)
for key, values := range r.Form {
if strings.HasPrefix(key, SEARCH_KEY_PREFIX) && len(values) > 0 {
identifier := strings.TrimPrefix(key, SEARCH_KEY_PREFIX)
if len(values) == 1 && values[0] == "__EMPTY_ARRAY__" {
filter.Search = append(filter.Search, Search{
Identifier: identifier,
Values: []string{},
})
} else {
filter.Search = append(filter.Search, Search{
Identifier: identifier,
Values: values,
})
}
}
}
return filter
}
func (p *Pagination) GeneratePagination(totalItemsInSet int64, itemsDisplayedThisPage int) {
p.TotalItems = int(totalItemsInSet)
p.ItemsThisPage = itemsDisplayedThisPage
if p.MaxItemsPerPage == 0 {
p.MaxItemsPerPage = FILTER_DEFAULT_MAX_ITEMS
}
if p.MaxItemsPerPage == -1 {
p.TotalPages = 1
p.CurrentPage = 1
p.PreviousPage = 1
p.NextPage = 1
if p.TotalItems != 0 {
p.ViewRangeLower = 1
} else {
p.ViewRangeLower = 0
}
p.ViewRangeUpper = p.TotalItems
return
}
if p.MaxItemsPerPage == 0 {
p.TotalPages = 1
} else {
p.TotalPages = p.TotalItems / p.MaxItemsPerPage
if p.TotalItems%p.MaxItemsPerPage != 0 {
p.TotalPages++
}
}
if p.TotalPages == 0 {
p.TotalPages = 1
}
if p.CurrentPage < 1 {
p.CurrentPage = 1
p.PreviousPage = 1
} else {
p.PreviousPage = p.CurrentPage - 1
}
if p.TotalItems != 0 {
p.ViewRangeLower = p.MaxItemsPerPage*p.CurrentPage - p.MaxItemsPerPage + 1
} else {
p.ViewRangeLower = 0
}
p.ViewRangeUpper = p.MaxItemsPerPage*p.CurrentPage - p.MaxItemsPerPage + p.ItemsThisPage
if p.CurrentPage >= p.TotalPages {
p.CurrentPage = p.TotalPages
p.NextPage = p.TotalPages
} else {
p.NextPage = p.CurrentPage + 1
}
}
// PaginateSlice performs in-memory pagination on a slice.
func PaginateSlice[T any](arr []T, f Filter) []T {
if !f.Pagination.Disabled {
if f.Pagination.CurrentPage <= 0 {
f.Pagination.CurrentPage = 1
}
if f.Pagination.MaxItemsPerPage > 0 {
offset := (f.Pagination.CurrentPage - 1) * f.Pagination.MaxItemsPerPage
limit := f.Pagination.MaxItemsPerPage
if offset > len(arr) {
arr = []T{}
} else if offset+limit > len(arr) {
arr = arr[offset:]
} else {
arr = arr[offset : offset+limit]
}
}
}
return arr
}
// LikeNonAlphaNumeric creates a condition that strips non-alphanumeric (except space)
// characters from the column and matches against the sanitized search value.
func LikeNonAlphaNumeric(columnName string, searchValue string, cond Cond) Cond {
searchSanitized := SanitizeAlphaNum(strings.ToLower(searchValue))
return cond.And(RawCond(
"regexp_replace(lower("+columnName+"), '[^a-zA-Z0-9 ]', '', 'g') LIKE ?",
"%"+searchSanitized+"%",
))
}
// LikeNonAlphaNumericStrict creates a condition that strips ALL non-alphanumeric
// characters (including spaces) from the column and matches against the sanitized search value.
func LikeNonAlphaNumericStrict(columnName string, searchValue string, cond Cond) Cond {
searchSanitized := SanitizeAlphaNumStrict(strings.ToLower(searchValue))
return cond.And(RawCond(
"regexp_replace(lower("+columnName+"), '[^a-zA-Z0-9]', '', 'g') LIKE ?",
"%"+searchSanitized+"%",
))
}
// IsEmpty returns true if the filter has no search values and no order by set.
func (f *Filter) IsEmpty() bool {
if f.OrderBy.Identifier != "" {
return false
}
for _, s := range f.Search {
if len(s.Values) > 0 && s.Values[0] != "" {
return false
}
}
return true
}
// ToQueryString converts the filter to a URL query string.
func (f *Filter) ToQueryString() string {
params := make([]string, 0)
for _, s := range f.Search {
for _, v := range s.Values {
if v != "" {
params = append(params, SEARCH_KEY_PREFIX+s.Identifier+"="+v)
}
}
}
if f.OrderBy.Identifier != "" {
params = append(params, ORDER_BY_KEY+"="+f.OrderBy.Identifier)
if f.OrderBy.Descending {
params = append(params, "order_desc=true")
}
}
return strings.Join(params, "&")
}

25
dbutil/registry.go Normal file
View File

@@ -0,0 +1,25 @@
package dbutil
import "reflect"
// tableRegistry maps a model struct type to its database table name. The query
// builder (T[M]) resolves table names through it, so the framework never has to
// import the application's models package. Apps populate it once at startup via
// Register / RegisterAll — typically from an init() in their models package:
//
// func init() { dbutil.RegisterAll(Tables) }
var tableRegistry = map[reflect.Type]string{}
// Register maps a single model type to a table name.
func Register(t reflect.Type, name string) { tableRegistry[t] = name }
// RegisterAll merges a whole type->table map (e.g. an app's models.Tables) into
// the registry.
func RegisterAll(m map[reflect.Type]string) {
for k, v := range m {
tableRegistry[k] = v
}
}
// tableNameFor returns the registered table name for t, or "" if none is set.
func tableNameFor(t reflect.Type) string { return tableRegistry[t] }

View File

@@ -0,0 +1,52 @@
package dbutil
// Test-only model structs for builder and automapper tests.
// These are decoupled from the real models package so that tests
// do not break when application models change.
import (
"reflect"
"time"
"github.com/google/uuid"
)
// testUser mirrors a typical user table.
type testUser struct {
ID uuid.UUID `db:"id"`
Username string `db:"username"`
Email string `db:"email"`
FirstName string `db:"first_name"`
LastName string `db:"last_name"`
Password string `db:"password"`
LoginCount int32 `db:"login_count"`
Created time.Time `db:"created"`
Active bool `db:"active"`
}
// testSession mirrors a session / identity table.
type testSession struct {
ID uuid.UUID `db:"id"`
Key string `db:"key"`
UserID uuid.UUID `db:"user_id"`
OrgID *uuid.UUID `db:"org_id"`
Created time.Time `db:"created"`
UserAgent string `db:"user_agent"`
Revoked bool `db:"revoked"`
}
// testMembership mirrors an org-user / membership table.
type testMembership struct {
ID uuid.UUID `db:"id"`
UserID uuid.UUID `db:"user_id"`
OrgID uuid.UUID `db:"org_id"`
CreatedBy *uuid.UUID `db:"created_by"`
Joined time.Time `db:"joined"`
LoginCount int32 `db:"login_count"`
}
func init() {
Register(reflect.TypeOf(testUser{}), "test_user")
Register(reflect.TypeOf(testSession{}), "test_session")
Register(reflect.TypeOf(testMembership{}), "test_membership")
}