From f9befa1b0aa87f9c9b0a1b8f4521a78c930b4470 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Mon, 29 Jun 2026 17:46:54 -0700 Subject: [PATCH] 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 --- .gitignore | 1 + apply.go | 112 +++++++++++ utility_test.go => apply_test.go | 0 dbx.go | 306 ------------------------------- dest.go | 38 ++++ doc.go | 42 +++++ exec.go | 69 +++++++ filter.go | 13 ++ interfaces.go | 54 ++++++ jet_columns.go | 21 +++ jet_expr.go | 26 +++ jet_types.go | 24 +++ module.go | 138 ++++++++++++++ ptr.go | 65 +++++++ query.go | 49 +++++ utility.go | 277 ---------------------------- 16 files changed, 652 insertions(+), 583 deletions(-) create mode 100644 .gitignore create mode 100644 apply.go rename utility_test.go => apply_test.go (100%) delete mode 100644 dbx.go create mode 100644 dest.go create mode 100644 doc.go create mode 100644 exec.go create mode 100644 filter.go create mode 100644 interfaces.go create mode 100644 jet_columns.go create mode 100644 jet_expr.go create mode 100644 jet_types.go create mode 100644 module.go create mode 100644 ptr.go create mode 100644 query.go delete mode 100644 utility.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d83068 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +coverage.out diff --git a/apply.go b/apply.go new file mode 100644 index 0000000..a40137d --- /dev/null +++ b/apply.go @@ -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 +} diff --git a/utility_test.go b/apply_test.go similarity index 100% rename from utility_test.go rename to apply_test.go diff --git a/dbx.go b/dbx.go deleted file mode 100644 index 0813968..0000000 --- a/dbx.go +++ /dev/null @@ -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 -} diff --git a/dest.go b/dest.go new file mode 100644 index 0000000..9f97804 --- /dev/null +++ b/dest.go @@ -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 +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..86a4186 --- /dev/null +++ b/doc.go @@ -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 diff --git a/exec.go b/exec.go new file mode 100644 index 0000000..d9e8a64 --- /dev/null +++ b/exec.go @@ -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 +} diff --git a/filter.go b/filter.go new file mode 100644 index 0000000..00e6327 --- /dev/null +++ b/filter.go @@ -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 +} diff --git a/interfaces.go b/interfaces.go new file mode 100644 index 0000000..97d6f0d --- /dev/null +++ b/interfaces.go @@ -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) +} diff --git a/jet_columns.go b/jet_columns.go new file mode 100644 index 0000000..3f013db --- /dev/null +++ b/jet_columns.go @@ -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 +} diff --git a/jet_expr.go b/jet_expr.go new file mode 100644 index 0000000..01146cf --- /dev/null +++ b/jet_expr.go @@ -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 +} diff --git a/jet_types.go b/jet_types.go new file mode 100644 index 0000000..05d2fcb --- /dev/null +++ b/jet_types.go @@ -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 +} diff --git a/module.go b/module.go new file mode 100644 index 0000000..521a339 --- /dev/null +++ b/module.go @@ -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 +} diff --git a/ptr.go b/ptr.go new file mode 100644 index 0000000..645dcb2 --- /dev/null +++ b/ptr.go @@ -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 +} diff --git a/query.go b/query.go new file mode 100644 index 0000000..a682852 --- /dev/null +++ b/query.go @@ -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 +} diff --git a/utility.go b/utility.go deleted file mode 100644 index c9c53d0..0000000 --- a/utility.go +++ /dev/null @@ -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 -}