refactor: split dbx package into domain files

Reorganize monolithic dbx.go and utility.go into focused files by
concern, add package doc.go, and gitignore coverage.out.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 17:46:54 -07:00
parent 14686a83cb
commit f9befa1b0a
16 changed files with 652 additions and 583 deletions
+1
View File
@@ -0,0 +1 @@
coverage.out
+112
View File
@@ -0,0 +1,112 @@
package dbx
import (
"reflect"
"github.com/go-jet/jet/v2/mysql"
"golang.org/x/exp/constraints"
)
type ApplyInterface[T any] interface {
Equal(T) bool
IsZero() bool
}
// ApplyPtr compares the existing value with a new value and returns the updated value if they differ.
// If the new value is nil, the existing value is retained. If the new value is a zero-value, the
// existing value is NOT retained, it will be set to nil. If the value is changed, targetColumn is pushed
// to updatedColumns.
func ApplyPtr[T constraints.Float | constraints.Integer | string | bool](
existing *T,
newVal *T,
updatedColumns *mysql.ColumnList,
targetColumn mysql.Column,
) *T {
if newVal == nil {
return existing
}
if reflect.ValueOf(*newVal).IsZero() {
newVal = nil
}
if newVal == nil && existing == nil || newVal != nil && existing != nil && *existing == *newVal {
return existing
}
*updatedColumns = append(*updatedColumns, targetColumn)
return newVal
}
// ApplyComplexPtr compares the existing value with a new value and returns the updated value if they differ.
// The new value may be of a different type (e.g. existing is uint16 and new is uint64), but it will be
// converted to match the current type resulting in potential loss of data. If the new value is nil, the
// existing value is retained. If the new value is a zero-value, the existing value is NOT retained, it
// will be set to nil. If the value is changed, targetColumn is pushed to updatedColumns.
func ApplyComplexPtr[
Existing constraints.Float | constraints.Integer,
New constraints.Float | constraints.Integer,
](
existing *Existing,
newVal *New,
updatedColumns *mysql.ColumnList,
targetColumn mysql.Column,
) *Existing {
if newVal == nil {
return existing
}
cast := Existing(*newVal)
if existing != nil && *existing == cast {
return existing
}
if reflect.ValueOf(cast).IsZero() {
if existing != nil {
*updatedColumns = append(*updatedColumns, targetColumn)
}
return nil
}
*updatedColumns = append(*updatedColumns, targetColumn)
return &cast
}
// ApplyInterfacePtr compares the existing value with a new value and returns the updated value if
// they differ. Comparable types must have IsZero and Equal methods. If the new value is nil, the
// existing value is retained. If the new value is a zero-value, the existing value is NOT retained,
// it will be set to nil. If the value is changed, targetColumn is pushed to updatedColumns.
func ApplyInterfacePtr[T ApplyInterface[T]](
existing *T,
newVal *T,
updatedColumns *mysql.ColumnList,
targetColumn mysql.Column,
) *T {
if newVal == nil {
return existing
}
if existing != nil && (*existing).Equal(*newVal) {
return existing
}
if (*newVal).IsZero() {
if existing != nil {
*updatedColumns = append(*updatedColumns, targetColumn)
}
return nil
}
*updatedColumns = append(*updatedColumns, targetColumn)
return newVal
}
// ApplyVal compares the existing value with a pointer to a new value and returns the updated value if they
// differ. If the new value is nil, the existing value is retained. If the value is changed, targetColumn
// is pushed to updatedColumns
func ApplyVal[T constraints.Float | constraints.Integer | string | bool](
existing T,
newVal *T,
updatedColumns *mysql.ColumnList,
targetColumn mysql.Column,
) T {
if newVal == nil {
return existing
}
if existing == *newVal {
return existing
}
*updatedColumns = append(*updatedColumns, targetColumn)
return *newVal
}
View File
-306
View File
@@ -1,306 +0,0 @@
package dbx
import (
"context"
"database/sql"
"errors"
"gitea.auvem.com/go-toolkit/app"
"gitea.auvem.com/go-toolkit/dbx/internal/dbxshared"
"github.com/go-jet/jet/v2/qrm"
)
const (
// ModuleDBName is the name of the database module.
ModuleDBName = "database"
// DialectPostgres is the PostgreSQL dialect.
DialectPostgres Dialect = "postgres"
// DialectMySQL is the MySQL dialect.
DialectMySQL Dialect = "mysql"
)
// Dialect is the SQL dialect used by the database connection.
type Dialect string
// String implements the Stringer interface for Dialect.
func (d Dialect) String() string {
return string(d)
}
// SQLOFunc is a function that returns a *sql.DB pointer.
type SQLOFunc = func() *sql.DB
// Queryable interface is an SQL driver object that can execute SQL statements
// for Jet.
type Queryable interface {
qrm.Queryable
Query(string, ...any) (*sql.Rows, error)
}
// Executable interface is an SQL driver object that can execute SQL statements
// for Jet.
type Executable interface {
qrm.Executable
Exec(string, ...any) (sql.Result, error)
}
// ExecutableTx interface is an SQL driver object that implements the Executable
// interface and can also begin a transaction.
type ExecutableTx interface {
Executable
Begin() (*sql.Tx, error)
}
// QueryExec interface is an SQL driver object that can execute SQL statements for Jet
// and query results.
type QueryExec interface {
Queryable
Executable
}
// QueryExecTx interface is an SQL driver object that can execute SQL statements for
// Jet, query results, and begin a transaction.
type QueryExecTx interface {
Queryable
ExecutableTx
}
// Statement is a common Jet statement for all SQL operations.
type Statement interface {
Query(db qrm.Queryable, destination any) error
QueryContext(ctx context.Context, db qrm.Queryable, destination any) error
Exec(db qrm.Executable) (sql.Result, error)
ExecContext(ctx context.Context, db qrm.Executable) (sql.Result, error)
}
// dbState stores package-level state for the database connection.
type dbState struct {
// sqlDB stores the current SQL database handle.
sqlDB *sql.DB
// config stores the database connection configuration.
config *DBConfig
// dialect is the SQL dialect used by the database connection.
dialect Dialect
// debugLog indicates whether debug logging is enabled.
debugLog bool
}
var (
// ErrNoRows is returned when a query returns no rows.
ErrNoRows = qrm.ErrNoRows
// ErrValueIsZero is returned when an expected value is missing.
ErrValueIsZero = errors.New("value is zero-value for type")
state = dbState{}
)
// SQLO returns the current SQL database handle.
func SQLO() *sql.DB {
dbxshared.DBModule.RequireLoaded("dbx.SQLO requires database module") // ensure the module is loaded before accessing the database
if state.sqlDB == nil {
panic("SQL database not initialized")
}
return state.sqlDB
}
// ModuleDB returns the database module with the provided configuration.
// dialect specifies the SQL dialect to use (e.g., DialectPostgres, DialectMySQL).
// config specifies the database connection configuration.
// forceDebugLog forces debug logging to be enabled regardless of the config setting.
func ModuleDB(dialect Dialect, config *DBConfig, forceDebugLog bool) *app.Module {
if dbxshared.DBModule != nil {
panic("ModuleDB initialized multiple times")
}
if config == nil {
panic("ModuleDB requires a non-nil DBConfig")
}
state.config = config // store configuration at package level
state.dialect = dialect // store dialect at package level
state.debugLog = config.DebugLog || forceDebugLog // force debug logging if requested
dbxshared.DBModule = app.NewModule(ModuleDBName, app.ModuleOpts{
Setup: setupDB,
Teardown: teardownDB,
})
return dbxshared.DBModule
}
// Fetch queries the database and returns the result as a slice. If the query
// returns no rows, it returns an empty slice and no error.
func Fetch[T any](sqlo Queryable, stmt Statement) ([]*T, error) {
var result []*T
if err := stmt.Query(sqlo, &result); err != nil && !errors.Is(err, ErrNoRows) {
return nil, err
}
return result, nil
}
// MustFetch queries the database and returns the result as a slice. If the query
// returns no rows, it returns an empty slice and the desired error.
func MustFetch[T any](sqlo Queryable, stmt Statement, notFoundErr error) ([]*T, error) {
result, err := Fetch[T](sqlo, stmt)
if err != nil {
return nil, err
}
if len(result) == 0 {
return nil, notFoundErr // return the desired error if no rows found
}
return result, nil // return the fetched results
}
// FetchOne queries the database and returns a single result. If the query
// returns no rows, it returns nil and no error.
func FetchOne[T any](sqlo Queryable, stmt Statement) (*T, error) {
result, err := Fetch[T](sqlo, stmt)
if err != nil {
return nil, err
}
if len(result) == 0 {
return nil, nil // no rows found, return nil
}
return result[0], nil // return the first (and only) result
}
// MustFetchOne queries the database and returns a single result. If the query
// returns no rows, it returns nil and the desired error.
func MustFetchOne[T any](sqlo Queryable, stmt Statement, notFoundErr error) (*T, error) {
result, err := MustFetch[T](sqlo, stmt, notFoundErr)
if err != nil {
return nil, err
}
return result[0], nil // return the first (and only) result
}
// Insert executes an insert statement, returning the last inserted ID or an
// error if the insert fails.
func Insert(sqlo Executable, stmt Statement) (uint64, error) {
res, err := stmt.Exec(sqlo)
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
if err != nil {
return 0, err
}
if id < 1 {
return 0, errors.New("inserted ID is less than 1")
}
return uint64(id), nil
}
// InsertReturning executes an insert statement that returns the inserted row.
// The statement MUST be a Jet InsertStatement with a RETURNING clause. Returns
// the inserted row object T or an error if the insert fails or no rows are returned.
func InsertReturning[T any](sqlo Queryable, stmt Statement) (*T, error) {
var result T
err := stmt.Query(sqlo, &result)
if err != nil {
return nil, err
}
return &result, nil
}
// Update executes an update statement, returning an error if the update fails.
func Update(sqlo Executable, stmt Statement) error {
_, err := stmt.Exec(sqlo)
return err
}
// UpdateAffected executes an update statement and returns the number of rows
// affected and an error if any.
func UpdateAffected(sqlo Executable, stmt Statement) (int64, error) {
res, err := stmt.Exec(sqlo)
if err != nil {
return 0, err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return 0, err
}
return rowsAffected, nil
}
// UpdateReturning executes an update statement that returns the updated row.
// The statement MUST be a Jet UpdateStatement with a RETURNING clause. Returns
// the updated row object T or an error if the update fails or no rows are returned.
func UpdateReturning[T any](sqlo Queryable, stmt Statement) (*T, error) {
var result T
err := stmt.Query(sqlo, &result)
if err != nil {
return nil, err
}
return &result, nil
}
// setupDB connects to the database.
func setupDB(m *app.Module) error {
if state.sqlDB != nil && state.sqlDB.Ping() == nil {
m.Logger().Warn("Database connection already established")
return nil
}
logArgs := []any{
"user", state.config.User,
"name", state.config.Name,
"uri", state.config.URI,
"dialect", state.dialect,
}
var err error
state.sqlDB, err = sql.Open(string(state.dialect), state.config.ConnectionString(state.dialect))
if err != nil {
logArgs = append(logArgs, "err", err)
m.Logger().Error("Couldn't open SQL database", logArgs...)
return err
}
if err := state.sqlDB.Ping(); err != nil {
logArgs = append(logArgs, "err", err)
m.Logger().Error("Couldn't ping SQL database", logArgs...)
return err
}
state.sqlDB.SetMaxOpenConns(state.config.MaxConn)
stats := state.sqlDB.Stats()
m.Logger().Info(
"Connected to SQL database",
"user", state.config.User,
"name", state.config.Name,
"uri", state.config.URI,
"maxConnections", stats.MaxOpenConnections,
"currConnections", stats.OpenConnections,
)
if state.debugLog {
dbxshared.InitLogger(state.dialect) // initialize the logger for the specified dialect
}
return nil
}
// teardownDB closes the database connection.
func teardownDB(m *app.Module) error {
if state.sqlDB == nil {
return nil
}
if err := state.sqlDB.Close(); err != nil {
m.Logger().Error("Couldn't close database", "err", err)
return err
}
m.Logger().Info("Closed database connection")
return nil
}
+38
View File
@@ -0,0 +1,38 @@
package dbx
import (
"reflect"
"strings"
"gitea.auvem.com/go-toolkit/dbx/internal/dbxshared"
)
// DestName returns the name of the type passed as `destTypeStruct` as a string,
// normalized for compatibility with the Jet QRM.
func DestName(destTypeStruct any, path ...string) string {
v := reflect.ValueOf(destTypeStruct)
for v.Kind() == reflect.Pointer {
v = v.Elem()
}
destIdent := v.Type().String()
destIdent = destIdent[strings.LastIndex(destIdent, ".")+1:]
for i, p := range path {
if v.Kind() != reflect.Struct {
dbxshared.DBModule.Logger().Error("DestName: path parent is not a struct", "path", destIdent+"."+strings.Join(path[:i+1], "."))
return ""
}
v = v.FieldByName(p)
if !v.IsValid() {
dbxshared.DBModule.Logger().Error("DestName: field does not exist", "path", destIdent+"."+strings.Join(path[:i+1], "."))
return ""
}
destIdent += "." + p
}
return destIdent
}
+42
View File
@@ -0,0 +1,42 @@
// Package dbx provides database utilities built on go-jet/jet and go-toolkit/app.
//
// # Module lifecycle
//
// Call [ModuleDB] once to register the database [app.Module], then use [SQLO]
// to access the connection pool. Only one database per process is supported.
//
// # Query and mutation helpers
//
// [Fetch], [FetchOne], [Insert], and [Update] wrap Jet [Statement] execution
// with consistent error semantics. Must* variants return a caller-provided error
// when no rows are found.
//
// # Jet column utilities
//
// Dialect-neutral union types ([Column], [ColumnList]) and helpers for column
// lists ([NormalCols]) and expression building ([ExprValues]).
//
// Partial-update helpers ([ApplyPtr], [ApplyVal]) track changed fields for
// repository patch logic.
//
// # Identifier types
//
// [UUID] wraps segmentio/ksuid for GraphQL APIs. [JSONB] provides map-based
// JSON column scanning for Postgres JSONB and MySQL JSON.
//
// # Pointer and string utilities
//
// [Ptr], [Val], [TrimPtr], [StringToFilter], and related helpers for nullable
// field handling in repository code.
//
// # Debug logging
//
// Enable [DBConfig.DebugLog] and blank-import dbxm (MySQL) or dbxp (Postgres)
// to register Jet query debug output.
//
// # Dialect notes
//
// Use [Insert] with LastInsertId on MySQL; Postgres callers should use
// [InsertReturning]. [InsertReturning] and [UpdateReturning] require Jet
// RETURNING clauses.
package dbx
+69
View File
@@ -0,0 +1,69 @@
package dbx
import "errors"
// Insert executes an insert statement, returning the last inserted ID or an
// error if the insert fails.
func Insert(sqlo Executable, stmt Statement) (uint64, error) {
res, err := stmt.Exec(sqlo)
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
if err != nil {
return 0, err
}
if id < 1 {
return 0, errors.New("inserted ID is less than 1")
}
return uint64(id), nil
}
// InsertReturning executes an insert statement that returns the inserted row.
// The statement MUST be a Jet InsertStatement with a RETURNING clause. Returns
// the inserted row object T or an error if the insert fails or no rows are returned.
func InsertReturning[T any](sqlo Queryable, stmt Statement) (*T, error) {
var result T
err := stmt.Query(sqlo, &result)
if err != nil {
return nil, err
}
return &result, nil
}
// Update executes an update statement, returning an error if the update fails.
func Update(sqlo Executable, stmt Statement) error {
_, err := stmt.Exec(sqlo)
return err
}
// UpdateAffected executes an update statement and returns the number of rows
// affected and an error if any.
func UpdateAffected(sqlo Executable, stmt Statement) (int64, error) {
res, err := stmt.Exec(sqlo)
if err != nil {
return 0, err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return 0, err
}
return rowsAffected, nil
}
// UpdateReturning executes an update statement that returns the updated row.
// The statement MUST be a Jet UpdateStatement with a RETURNING clause. Returns
// the updated row object T or an error if the update fails or no rows are returned.
func UpdateReturning[T any](sqlo Queryable, stmt Statement) (*T, error) {
var result T
err := stmt.Query(sqlo, &result)
if err != nil {
return nil, err
}
return &result, nil
}
+13
View File
@@ -0,0 +1,13 @@
package dbx
import "strings"
// StringToFilter processes a string to be used as a filter in an SQL LIKE
// statement. It replaces all spaces with % and adds % to the beginning and
// end of the string.
func StringToFilter(str string) string {
str = strings.Trim(str, "%")
str = strings.ReplaceAll(str, " ", "%")
str = "%" + str + "%"
return str
}
+54
View File
@@ -0,0 +1,54 @@
package dbx
import (
"context"
"database/sql"
"github.com/go-jet/jet/v2/qrm"
)
// SQLOFunc is a function that returns a *sql.DB pointer.
type SQLOFunc = func() *sql.DB
// Queryable interface is an SQL driver object that can execute SQL statements
// for Jet.
type Queryable interface {
qrm.Queryable
Query(string, ...any) (*sql.Rows, error)
}
// Executable interface is an SQL driver object that can execute SQL statements
// for Jet.
type Executable interface {
qrm.Executable
Exec(string, ...any) (sql.Result, error)
}
// ExecutableTx interface is an SQL driver object that implements the Executable
// interface and can also begin a transaction.
type ExecutableTx interface {
Executable
Begin() (*sql.Tx, error)
}
// QueryExec interface is an SQL driver object that can execute SQL statements for Jet
// and query results.
type QueryExec interface {
Queryable
Executable
}
// QueryExecTx interface is an SQL driver object that can execute SQL statements for
// Jet, query results, and begin a transaction.
type QueryExecTx interface {
Queryable
ExecutableTx
}
// Statement is a common Jet statement for all SQL operations.
type Statement interface {
Query(db qrm.Queryable, destination any) error
QueryContext(ctx context.Context, db qrm.Queryable, destination any) error
Exec(db qrm.Executable) (sql.Result, error)
ExecContext(ctx context.Context, db qrm.Executable) (sql.Result, error)
}
+21
View File
@@ -0,0 +1,21 @@
package dbx
import "github.com/go-jet/jet/v2/mysql"
// NormalCols processes a list of columns and strips out any that implement any of
// ColumnTimestamp, ColumnTime, or ColumnDate.
func NormalCols[CL ColumnList](cols ...Column) CL {
res := make(CL, 0)
for _, col := range cols {
switch col.(type) {
case mysql.ColumnTimestamp, // = postgres.ColumnTimestamp
mysql.ColumnTime, // = postgres.ColumnTime
mysql.ColumnDate: // = postgres.ColumnDate
default:
res = append(res, col)
}
}
return res
}
+26
View File
@@ -0,0 +1,26 @@
package dbx
import (
"fmt"
"github.com/go-jet/jet/v2/mysql"
)
// ExprValues converts a list of values to a list of mysql.Expression values using
// function f to transform the values (mysql.String for strings, mysql.Uint64, etc).
func ExprValues[T any](values []T, f func(T) mysql.Expression) []mysql.Expression {
expressions := make([]mysql.Expression, len(values))
for i, v := range values {
expressions[i] = f(v)
}
return expressions
}
// ExprStringers converts a list of fmt.Stringers to a list of mysql.Expression values.
func ExprStringers(values []fmt.Stringer) []mysql.Expression {
expressions := make([]mysql.Expression, len(values))
for i, v := range values {
expressions[i] = mysql.String(v.String())
}
return expressions
}
+24
View File
@@ -0,0 +1,24 @@
package dbx
import (
"github.com/go-jet/jet/v2/mysql"
"github.com/go-jet/jet/v2/postgres"
)
// Column is a union type for mysql.Column and postgres.Column
type Column interface {
mysql.Column
postgres.Column
}
// ColumnList is a union type for mysql.ColumnList and postgres.ColumnList
type ColumnList interface {
mysql.ColumnList
postgres.ColumnList
}
// BoolExpression is a union type for mysql.BoolExpression and postgres.BoolExpression
type BoolExpression interface {
mysql.BoolExpression
postgres.BoolExpression
}
+138
View File
@@ -0,0 +1,138 @@
package dbx
import (
"database/sql"
"errors"
"gitea.auvem.com/go-toolkit/app"
"gitea.auvem.com/go-toolkit/dbx/internal/dbxshared"
"github.com/go-jet/jet/v2/qrm"
)
const (
// ModuleDBName is the name of the database module.
ModuleDBName = "database"
// DialectPostgres is the PostgreSQL dialect.
DialectPostgres Dialect = "postgres"
// DialectMySQL is the MySQL dialect.
DialectMySQL Dialect = "mysql"
)
// Dialect is the SQL dialect used by the database connection.
type Dialect string
// String implements the Stringer interface for Dialect.
func (d Dialect) String() string {
return string(d)
}
// dbState stores package-level state for the database connection.
type dbState struct {
sqlDB *sql.DB
config *DBConfig
dialect Dialect
debugLog bool
}
var (
// ErrNoRows is returned when a query returns no rows.
ErrNoRows = qrm.ErrNoRows
// ErrValueIsZero is returned when an expected value is missing.
ErrValueIsZero = errors.New("value is zero-value for type")
state = dbState{}
)
// SQLO returns the current SQL database handle.
func SQLO() *sql.DB {
dbxshared.DBModule.RequireLoaded("dbx.SQLO requires database module")
if state.sqlDB == nil {
panic("SQL database not initialized")
}
return state.sqlDB
}
// ModuleDB returns the database module with the provided configuration.
// dialect specifies the SQL dialect to use (e.g., DialectPostgres, DialectMySQL).
// config specifies the database connection configuration.
// forceDebugLog forces debug logging to be enabled regardless of the config setting.
func ModuleDB(dialect Dialect, config *DBConfig, forceDebugLog bool) *app.Module {
if dbxshared.DBModule != nil {
panic("ModuleDB initialized multiple times")
}
if config == nil {
panic("ModuleDB requires a non-nil DBConfig")
}
state.config = config
state.dialect = dialect
state.debugLog = config.DebugLog || forceDebugLog
dbxshared.DBModule = app.NewModule(ModuleDBName, app.ModuleOpts{
Setup: setupDB,
Teardown: teardownDB,
})
return dbxshared.DBModule
}
func setupDB(m *app.Module) error {
if state.sqlDB != nil && state.sqlDB.Ping() == nil {
m.Logger().Warn("Database connection already established")
return nil
}
logArgs := []any{
"user", state.config.User,
"name", state.config.Name,
"uri", state.config.URI,
"dialect", state.dialect,
}
var err error
state.sqlDB, err = sql.Open(string(state.dialect), state.config.ConnectionString(state.dialect))
if err != nil {
logArgs = append(logArgs, "err", err)
m.Logger().Error("Couldn't open SQL database", logArgs...)
return err
}
if err := state.sqlDB.Ping(); err != nil {
logArgs = append(logArgs, "err", err)
m.Logger().Error("Couldn't ping SQL database", logArgs...)
return err
}
state.sqlDB.SetMaxOpenConns(state.config.MaxConn)
stats := state.sqlDB.Stats()
m.Logger().Info(
"Connected to SQL database",
"user", state.config.User,
"name", state.config.Name,
"uri", state.config.URI,
"maxConnections", stats.MaxOpenConnections,
"currConnections", stats.OpenConnections,
)
if state.debugLog {
dbxshared.InitLogger(state.dialect)
}
return nil
}
func teardownDB(m *app.Module) error {
if state.sqlDB == nil {
return nil
}
if err := state.sqlDB.Close(); err != nil {
m.Logger().Error("Couldn't close database", "err", err)
return err
}
m.Logger().Info("Closed database connection")
return nil
}
+65
View File
@@ -0,0 +1,65 @@
package dbx
import (
"reflect"
"strings"
"time"
)
// NowPtr returns a pointer to the current time.
func NowPtr() *time.Time {
now := time.Now()
return &now
}
// Ptr returns a pointer to the given value of any scalar type. Returns nil if the value is a zero value.
func Ptr[T any](val T) *T {
if reflect.ValueOf(val).IsZero() {
return nil
}
return &val
}
// Val returns the value of the pointer to a scalar type, or the zero value if the pointer is nil.
func Val[T any](ptr *T) T {
if ptr == nil {
var zero T
return zero
}
return *ptr
}
// TrimPtr trims the whitespace from a pointer to a string and returns nil only if the pointer is nil.
func TrimPtr(s *string) *string {
if s == nil {
return nil
}
trimmed := strings.TrimSpace(*s)
return &trimmed
}
// TrimPtrToNil trims the whitespace from a pointer to a string and returns nil
// if the resulting string is empty or if the pointer is nil.
func TrimPtrToNil(s *string) *string {
if s == nil {
return nil
}
trimmed := strings.TrimSpace(*s)
if trimmed == "" {
return nil
}
return &trimmed
}
// IsZero checks if a pointer references the zero value of a given type and
// returns an error if this condition is met, otherwise returns nil if the
// pointer is nil or the value is not zero.
func IsZero[T any](ptr *T) error {
if ptr == nil {
return nil
}
if reflect.ValueOf(*ptr).IsZero() {
return ErrValueIsZero
}
return nil
}
+49
View File
@@ -0,0 +1,49 @@
package dbx
import "errors"
// Fetch queries the database and returns the result as a slice. If the query
// returns no rows, it returns an empty slice and no error.
func Fetch[T any](sqlo Queryable, stmt Statement) ([]*T, error) {
var result []*T
if err := stmt.Query(sqlo, &result); err != nil && !errors.Is(err, ErrNoRows) {
return nil, err
}
return result, nil
}
// MustFetch queries the database and returns the result as a slice. If the query
// returns no rows, it returns an empty slice and the desired error.
func MustFetch[T any](sqlo Queryable, stmt Statement, notFoundErr error) ([]*T, error) {
result, err := Fetch[T](sqlo, stmt)
if err != nil {
return nil, err
}
if len(result) == 0 {
return nil, notFoundErr
}
return result, nil
}
// FetchOne queries the database and returns a single result. If the query
// returns no rows, it returns nil and no error.
func FetchOne[T any](sqlo Queryable, stmt Statement) (*T, error) {
result, err := Fetch[T](sqlo, stmt)
if err != nil {
return nil, err
}
if len(result) == 0 {
return nil, nil
}
return result[0], nil
}
// MustFetchOne queries the database and returns a single result. If the query
// returns no rows, it returns nil and the desired error.
func MustFetchOne[T any](sqlo Queryable, stmt Statement, notFoundErr error) (*T, error) {
result, err := MustFetch[T](sqlo, stmt, notFoundErr)
if err != nil {
return nil, err
}
return result[0], nil
}
-277
View File
@@ -1,277 +0,0 @@
package dbx
import (
"fmt"
"reflect"
"strings"
"time"
"gitea.auvem.com/go-toolkit/dbx/internal/dbxshared"
"github.com/go-jet/jet/v2/mysql"
"github.com/go-jet/jet/v2/postgres"
"golang.org/x/exp/constraints"
)
// Column is a union type for mysql.Column and postgres.Column
type Column interface {
mysql.Column
postgres.Column
}
// ColumnList is a union type for mysql.ColumnList and postgres.ColumnList
type ColumnList interface {
mysql.ColumnList
postgres.ColumnList
}
// BoolExpression is a union type for mysql.BoolExpression and postgres.BoolExpression
type BoolExpression interface {
mysql.BoolExpression
postgres.BoolExpression
}
// StringToFilter processes a string to be used as a filter in an SQL LIKE
// statement. It replaces all spaces with % and adds % to the beginning and
// end of the string.
func StringToFilter(str string) string {
// Remove any existing leading or trailing % characters
str = strings.Trim(str, "%")
// Replace all spaces with % and add % to the beginning and end of the string
str = strings.ReplaceAll(str, " ", "%")
str = "%" + str + "%"
return str
}
// DestName returns the name of the type passed as `destTypeStruct` as a string,
// normalized for compatibility with the Jet QRM.
func DestName(destTypeStruct any, path ...string) string {
v := reflect.ValueOf(destTypeStruct)
for v.Kind() == reflect.Pointer {
v = v.Elem()
}
destIdent := v.Type().String()
destIdent = destIdent[strings.LastIndex(destIdent, ".")+1:]
for i, p := range path {
if v.Kind() != reflect.Struct {
dbxshared.DBModule.Logger().Error("DestName: path parent is not a struct", "path", destIdent+"."+strings.Join(path[:i+1], "."))
return ""
}
v = v.FieldByName(p)
if !v.IsValid() {
dbxshared.DBModule.Logger().Error("DestName: field does not exist", "path", destIdent+"."+strings.Join(path[:i+1], "."))
return ""
}
destIdent += "." + p
}
return destIdent
}
// NormalCols processes a list of columns and strips out any that implement any of
// ColumnTimestamp, ColumnTime, or ColumnDate.
func NormalCols[CL ColumnList](cols ...Column) CL {
res := make(CL, 0)
for _, col := range cols {
switch col.(type) {
case mysql.ColumnTimestamp, // = postgres.ColumnTimestamp
mysql.ColumnTime, // = postgres.ColumnTime
mysql.ColumnDate: // = postgres.ColumnDate
// skip time/date/timestamp columns
default:
res = append(res, col)
}
}
return res
}
// ExprValues converts a list of values to a list of mysql.Expression values using
// function f to transform the values (mysql.String for strings, mysql.Uint64, etc).
func ExprValues[T any](values []T, f func(T) mysql.Expression) []mysql.Expression {
expressions := make([]mysql.Expression, len(values))
for i, v := range values {
expressions[i] = f(v)
}
return expressions
}
// ExprStringers converts a list of fmt.Stringers to a list of mysql.Expression values.
func ExprStringers(values []fmt.Stringer) []mysql.Expression {
expressions := make([]mysql.Expression, len(values))
for i, v := range values {
expressions[i] = mysql.String(v.String())
}
return expressions
}
// NowPtr returns a pointer to the current time.
func NowPtr() *time.Time {
now := time.Now()
return &now
}
// Ptr returns a pointer to the given value of any scalar type. Returns nil if the value is a zero value.
func Ptr[T any](val T) *T {
if reflect.ValueOf(val).IsZero() {
return nil
}
return &val
}
// Val returns the value of the pointer to a scalar type, or the zero value if the pointer is nil.
func Val[T any](ptr *T) T {
if ptr == nil {
var zero T
return zero
}
return *ptr
}
// TrimPtr trims the whitespace from a pointer to a string and returns nil only if the pointer is nil.
func TrimPtr(s *string) *string {
if s == nil {
return nil
}
trimmed := strings.TrimSpace(*s)
return &trimmed
}
// TrimPtrToNil trims the whitespace from a pointer to a string and returns nil
// if the resulting string is empty or if the pointer is nil.
func TrimPtrToNil(s *string) *string {
if s == nil {
return nil
}
trimmed := strings.TrimSpace(*s)
if trimmed == "" {
return nil
}
return &trimmed
}
// IsZero checks if a pointer references the zero value of a given type and
// returns an error if this condition is met, otherwise returns nil if the
// pointer is nil or the value is not zero.
func IsZero[T any](ptr *T) error {
if ptr == nil {
return nil
}
if reflect.ValueOf(*ptr).IsZero() {
return ErrValueIsZero
}
return nil
}
// ApplyPtr compares the existing value with a new value and returns the updated value if they differ.
// If the new value is nil, the existing value is retained. If the new value is a zero-value, the
// existing value is NOT retained, it will be set to nil. If the value is changed, targetColumn is pushed
// to updatedColumns.
func ApplyPtr[T constraints.Float | constraints.Integer | string | bool](
existing *T,
newVal *T,
updatedColumns *mysql.ColumnList,
targetColumn mysql.Column,
) *T {
if newVal == nil {
return existing
}
if reflect.ValueOf(*newVal).IsZero() {
newVal = nil
}
if newVal == nil && existing == nil || newVal != nil && existing != nil && *existing == *newVal {
return existing
}
*updatedColumns = append(*updatedColumns, targetColumn)
return newVal
}
// ApplyComplexPtr compares the existing value with a new value and returns the updated value if they differ.
// The new value may be of a different type (e.g. existing is uint16 and new is uint64), but it will be
// converted to match the current type resulting in potential loss of data. If the new value is nil, the
// existing value is retained. If the new value is a zero-value, the existing value is NOT retained, it
// will be set to nil. If the value is changed, targetColumn is pushed to updatedColumns.
func ApplyComplexPtr[
Existing constraints.Float | constraints.Integer,
New constraints.Float | constraints.Integer,
](
existing *Existing,
newVal *New,
updatedColumns *mysql.ColumnList,
targetColumn mysql.Column,
) *Existing {
if newVal == nil {
return existing
}
cast := Existing(*newVal) // Convert new value to existing type
if existing != nil && *existing == cast {
return existing
}
if reflect.ValueOf(cast).IsZero() {
if existing != nil {
*updatedColumns = append(*updatedColumns, targetColumn)
}
return nil
} else {
*updatedColumns = append(*updatedColumns, targetColumn)
return &cast
}
}
type ApplyInterface[T any] interface {
Equal(T) bool
IsZero() bool
}
// ApplyInterfacePtr compares the existing value with a new value and returns the updated value if
// they differ. Comparable types must have IsZero and Equal methods. If the new value is nil, the
// existing value is retained. If the new value is a zero-value, the existing value is NOT retained,
// it will be set to nil. If the value is changed, targetColumn is pushed to updatedColumns.
func ApplyInterfacePtr[T ApplyInterface[T]](
existing *T,
newVal *T,
updatedColumns *mysql.ColumnList,
targetColumn mysql.Column,
) *T {
if newVal == nil {
return existing
}
if existing != nil && (*existing).Equal(*newVal) {
return existing
}
if (*newVal).IsZero() {
if existing != nil {
*updatedColumns = append(*updatedColumns, targetColumn)
}
return nil
} else {
*updatedColumns = append(*updatedColumns, targetColumn)
return newVal
}
}
// ApplyVal compares the existing value with a pointer to a new value and returns the updated value if they
// differ. If the new value is nil, the existing value is retained. If the value is changed, targetColumn
// is pushed to updatedColumns
func ApplyVal[T constraints.Float | constraints.Integer | string | bool](
existing T,
newVal *T,
updatedColumns *mysql.ColumnList,
targetColumn mysql.Column,
) T {
if newVal == nil {
return existing
}
if existing == *newVal {
return existing
}
*updatedColumns = append(*updatedColumns, targetColumn)
return *newVal
}