Initial add backend stuff
This commit is contained in:
554
dbutil/automapper.go
Normal file
554
dbutil/automapper.go
Normal 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
507
dbutil/automapper_test.go
Normal 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
1270
dbutil/builder.go
Normal file
File diff suppressed because it is too large
Load Diff
737
dbutil/builder_test.go
Normal file
737
dbutil/builder_test.go
Normal 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
64
dbutil/db_connect.go
Normal 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
306
dbutil/filters.go
Normal 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
25
dbutil/registry.go
Normal 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] }
|
||||
52
dbutil/test_models_test.go
Normal file
52
dbutil/test_models_test.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user