Compare commits
17 Commits
14686a83cb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e30272020 | |||
| a712ccc38f | |||
| eafe84ef31 | |||
| 338294f957 | |||
| 689e022e3e | |||
| 08f8387856 | |||
| fbb9c74aab | |||
| cc44931ea9 | |||
| 0f8e79a890 | |||
| 29b855f2b9 | |||
| 7647884464 | |||
| 63187ee905 | |||
| 8d697eda5b | |||
| 8053aafa6f | |||
| a303e025b2 | |||
| 984b52d198 | |||
| f9befa1b0a |
@@ -0,0 +1 @@
|
|||||||
|
coverage.out
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased (v1.0.0)
|
||||||
|
|
||||||
|
### Breaking changes
|
||||||
|
|
||||||
|
- Removed `UUID`, `NewUUID`, and `ParseUUID`.
|
||||||
|
- Added `StringKSUID` and `BinaryKSUID` with distinct SQL encodings and shared GraphQL string scalar transit.
|
||||||
|
- Removed `DBConfig.AutoMigrate`, `SQLOFunc`, and `JSONB.ToMap()`.
|
||||||
|
- Replaced `Column` / `ColumnList` intersection interfaces with dialect-neutral type aliases.
|
||||||
|
- `Apply*` and `Expr*` helpers now accept `Column` / `ColumnList` / `Expression` aliases instead of `mysql.*` types.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `StringKSUID.Equal` / `IsZero`, `BinaryKSUID.Equal` / `IsZero`, and nil sentinel vars for [ApplyInterfacePtr].
|
||||||
|
- `Query`, `MustQuery`, and `UpdateOne` helpers (+ Context variants).
|
||||||
|
- `WithTxValue` for transactional functions that return a value.
|
||||||
|
- `QueryCount`, `BuildQueryCountFn`, and `CountResult` for pagination counts.
|
||||||
|
- `StringKSUIDExpr`, `BinaryKSUIDExpr`, `ExprPtrs`, and flexible `Parse*Any` KSUID parsers.
|
||||||
|
- `MarshalUint64` and `UnmarshalUint64` for JS-safe GraphQL uint64 scalars.
|
||||||
|
- Context-aware variants for all query and mutation helpers.
|
||||||
|
- `Delete`, `DeleteAffected`, `WithTx`, `ContainsCol`, and `CurrentDialect()`.
|
||||||
|
- Package documentation (`doc.go`), expanded README, and subpackage docs for `dbxm` / `dbxp`.
|
||||||
|
- Comprehensive tests for KSUID types, CRUD helpers, column utilities, and module lifecycle.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Debug logger setup returns an error instead of panicking when `DebugLog` is enabled without a dialect logger blank import.
|
||||||
|
- `DestName` skips logging when the database module is not initialized.
|
||||||
|
- Source reorganized into domain-focused files (`query.go`, `exec.go`, `apply.go`, etc.).
|
||||||
@@ -1,3 +1,71 @@
|
|||||||
# dbx
|
# dbx
|
||||||
|
|
||||||
dbx (**D**ata**b**ase **E**extensions) is a small toolkit of common and reusable database helpers built around the [Jet](https://github.com/go-jet/jet) SQL builder. Provides an [app.Module](https://gitea.auvem.com/go-toolkit/app).
|
**D**ata**b**ase e**x**tensions — a small toolkit of reusable database helpers built on [Jet](https://github.com/go-jet/jet) and [go-toolkit/app](https://gitea.auvem.com/go-toolkit/app).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get gitea.auvem.com/go-toolkit/dbx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"gitea.auvem.com/go-toolkit/app"
|
||||||
|
"gitea.auvem.com/go-toolkit/dbx"
|
||||||
|
_ "gitea.auvem.com/go-toolkit/dbx/dbxm" // MySQL debug logging only
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := &dbx.DBConfig{
|
||||||
|
User: "user",
|
||||||
|
Password: "pass",
|
||||||
|
URI: "localhost:3306",
|
||||||
|
Name: "mydb",
|
||||||
|
MaxConn: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
modules := []*app.Module{
|
||||||
|
dbx.ModuleDB(dbx.DialectMySQL, cfg, false),
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Run(modules...)
|
||||||
|
db := dbx.SQLO()
|
||||||
|
_ = db
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For Postgres, use `dbx.DialectPostgres` and blank-import `dbxp` instead of `dbxm` when `DebugLog` is enabled.
|
||||||
|
|
||||||
|
## Helper catalog
|
||||||
|
|
||||||
|
| Area | Functions |
|
||||||
|
|------|-----------|
|
||||||
|
| Query | `Fetch`, `MustFetch`, `FetchOne`, `MustFetchOne`, `Query`, `MustQuery` (+ `*Context` variants) |
|
||||||
|
| Mutations | `Insert`, `InsertReturning`, `Update`, `UpdateAffected`, `UpdateOne`, `UpdateReturning`, `Delete`, `DeleteAffected` (+ `*Context` variants) |
|
||||||
|
| Transactions | `WithTx`, `WithTxValue` |
|
||||||
|
| Columns | `NormalCols`, `ContainsCol`, `ExprValues`, `ExprPtrs`, `ExprStringers`, `StringKSUIDExpr`, `BinaryKSUIDExpr`, `QueryCount`, `BuildQueryCountFn` |
|
||||||
|
| Partial update | `ApplyPtr`, `ApplyComplexPtr`, `ApplyInterfacePtr`, `ApplyVal` |
|
||||||
|
| Pointers | `Ptr`, `Val`, `NowPtr`, `TrimPtr`, `TrimPtrToNil`, `IsZero` |
|
||||||
|
| Types | `StringKSUID`, `BinaryKSUID`, `JSONB` |
|
||||||
|
| GraphQL | `MarshalUint64`, `UnmarshalUint64` |
|
||||||
|
|
||||||
|
## KSUID type selection
|
||||||
|
|
||||||
|
| Type | SQL column | Storage |
|
||||||
|
|------|------------|---------|
|
||||||
|
| `StringKSUID` | `VARCHAR(27)`, `TEXT` | Base62 string |
|
||||||
|
| `BinaryKSUID` | `BINARY(20)`, `BYTEA` | Raw 20 bytes |
|
||||||
|
|
||||||
|
Both types use identical GraphQL string scalar transit. Pick the type that matches your column encoding — Scan rejects ambiguous payloads.
|
||||||
|
|
||||||
|
## Dialect notes
|
||||||
|
|
||||||
|
- **MySQL inserts:** use `Insert` for `LastInsertId` workflows.
|
||||||
|
- **Postgres inserts:** use `InsertReturning` with a Jet `RETURNING` clause.
|
||||||
|
- **Debug logging:** requires blank-import of `dbxm` or `dbxp` matching your dialect. Without it, module setup returns an error when `DebugLog` is true.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Package docs are available on pkg.go.dev and via `go doc gitea.auvem.com/go-toolkit/dbx`.
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"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 *ColumnList,
|
||||||
|
targetColumn 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 *ColumnList,
|
||||||
|
targetColumn 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 *ColumnList,
|
||||||
|
targetColumn 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 *ColumnList,
|
||||||
|
targetColumn Column,
|
||||||
|
) T {
|
||||||
|
if newVal == nil {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
if existing == *newVal {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
*updatedColumns = append(*updatedColumns, targetColumn)
|
||||||
|
return *newVal
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ type DBConfig struct {
|
|||||||
URI string `validate:"hostname_port,required"`
|
URI string `validate:"hostname_port,required"`
|
||||||
Name string `validate:"required"`
|
Name string `validate:"required"`
|
||||||
MaxConn int `validate:"min=1,max=1000"`
|
MaxConn int `validate:"min=1,max=1000"`
|
||||||
AutoMigrate bool
|
|
||||||
DebugLog bool
|
DebugLog bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CountResult holds the count from a Jet COUNT query. Use with a SELECT that
|
||||||
|
// aliases the count as `CountResult.Count`, for example:
|
||||||
|
//
|
||||||
|
// SELECT(mysql.COUNT(col).AS("CountResult.Count"))
|
||||||
|
type CountResult struct {
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryCountFn counts rows matching pre-bound table and condition parameters.
|
||||||
|
type QueryCountFn func(sqlo Queryable) (int, error)
|
||||||
|
|
||||||
|
// QueryCount counts rows in tbl matching conds.
|
||||||
|
func QueryCount(
|
||||||
|
sqlo Queryable,
|
||||||
|
col Column,
|
||||||
|
tbl ReadableTable,
|
||||||
|
conds BoolExpression,
|
||||||
|
) (int, error) {
|
||||||
|
stmt := tbl.SELECT(mysql.COUNT(col).AS("CountResult.Count")).WHERE(conds)
|
||||||
|
var res CountResult
|
||||||
|
if err := stmt.Query(sqlo, &res); err != nil {
|
||||||
|
return 0, fmt.Errorf("query count: %w", err)
|
||||||
|
}
|
||||||
|
return res.Count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildQueryCountFn returns a QueryCountFn with col, tbl, and conds bound.
|
||||||
|
func BuildQueryCountFn(
|
||||||
|
col Column,
|
||||||
|
tbl ReadableTable,
|
||||||
|
conds BoolExpression,
|
||||||
|
) QueryCountFn {
|
||||||
|
return func(sqlo Queryable) (int, error) {
|
||||||
|
return QueryCount(sqlo, col, tbl, conds)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCountResult_Scan(t *testing.T) {
|
||||||
|
stmt := mockStatement{
|
||||||
|
queryContextFn: func(_ context.Context, dest any) error {
|
||||||
|
ptr := dest.(*CountResult)
|
||||||
|
ptr.Count = 3
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var scanned CountResult
|
||||||
|
err := stmt.Query(mockQueryable{}, &scanned)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 3, scanned.Count)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Package dbxm registers the MySQL Jet query debug logger for dbx.
|
||||||
|
//
|
||||||
|
// Blank-import this package when using dbx with MySQL and [dbx.DBConfig.DebugLog]
|
||||||
|
// is enabled:
|
||||||
|
//
|
||||||
|
// import _ "gitea.auvem.com/go-toolkit/dbx/dbxm"
|
||||||
|
package dbxm
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package dbxm_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.auvem.com/go-toolkit/dbx"
|
||||||
|
"gitea.auvem.com/go-toolkit/dbx/internal/dbxshared"
|
||||||
|
_ "gitea.auvem.com/go-toolkit/dbx/dbxm"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMySQLDebugLoggerRegistered(t *testing.T) {
|
||||||
|
err := dbxshared.InitLogger(dbx.DialectMySQL)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Package dbxp registers the Postgres Jet query debug logger for dbx.
|
||||||
|
//
|
||||||
|
// Blank-import this package when using dbx with Postgres and [dbx.DBConfig.DebugLog]
|
||||||
|
// is enabled:
|
||||||
|
//
|
||||||
|
// import _ "gitea.auvem.com/go-toolkit/dbx/dbxp"
|
||||||
|
package dbxp
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package dbxp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.auvem.com/go-toolkit/dbx"
|
||||||
|
"gitea.auvem.com/go-toolkit/dbx/internal/dbxshared"
|
||||||
|
_ "gitea.auvem.com/go-toolkit/dbx/dbxp"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPostgresDebugLoggerRegistered(t *testing.T) {
|
||||||
|
err := dbxshared.InitLogger(dbx.DialectPostgres)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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 {
|
||||||
|
destNameLogError("DestName: path parent is not a struct", destIdent+"."+strings.Join(path[:i+1], "."))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
v = v.FieldByName(p)
|
||||||
|
|
||||||
|
if !v.IsValid() {
|
||||||
|
destNameLogError("DestName: field does not exist", destIdent+"."+strings.Join(path[:i+1], "."))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
destIdent += "." + p
|
||||||
|
}
|
||||||
|
|
||||||
|
return destIdent
|
||||||
|
}
|
||||||
|
|
||||||
|
func destNameLogError(msg, path string) {
|
||||||
|
if dbxshared.DBModule == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbxshared.DBModule.Logger().Error(msg, "path", path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// 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. [CurrentDialect] reports the configured dialect.
|
||||||
|
// Only one database per process is supported.
|
||||||
|
//
|
||||||
|
// # Query and mutation helpers
|
||||||
|
//
|
||||||
|
// [Fetch], [FetchOne], [Query], [Insert], [Update], [Delete], and their Must*
|
||||||
|
// and Context variants wrap Jet [Statement] execution with consistent error
|
||||||
|
// semantics. [UpdateOne] requires at least one row affected.
|
||||||
|
// [WithTx] and [WithTxValue] run functions inside SQL transactions.
|
||||||
|
//
|
||||||
|
// # Jet column utilities
|
||||||
|
//
|
||||||
|
// Dialect-neutral type aliases ([Column], [ColumnList]) and helpers for column
|
||||||
|
// lists ([NormalCols], [ContainsCol]) and expression building ([ExprValues],
|
||||||
|
// [ExprPtrs], [StringKSUIDExpr], [BinaryKSUIDExpr]).
|
||||||
|
// [QueryCount] and [BuildQueryCountFn] support pagination total counts.
|
||||||
|
//
|
||||||
|
// Partial-update helpers ([ApplyPtr], [ApplyVal]) track changed fields for
|
||||||
|
// repository patch logic.
|
||||||
|
//
|
||||||
|
// # Identifier types
|
||||||
|
//
|
||||||
|
// [StringKSUID] and [BinaryKSUID] wrap segmentio/ksuid with storage-specific
|
||||||
|
// SQL encoding. Both implement [ApplyInterface] via Equal and IsZero for use
|
||||||
|
// with [ApplyInterfacePtr]. Expr returns a Jet expression via [StringKSUIDExpr]
|
||||||
|
// or [BinaryKSUIDExpr]. Both share identical GraphQL string scalar transit.
|
||||||
|
// Choose StringKSUID for text columns (VARCHAR, TEXT); choose BinaryKSUID for
|
||||||
|
// binary columns (BINARY(20), BYTEA). [ParseStringKSUIDAny] and
|
||||||
|
// [ParseBinaryKSUIDAny] accept flexible input for API boundaries.
|
||||||
|
//
|
||||||
|
// [JSONB] provides map-based JSON column scanning for Postgres JSONB and MySQL JSON.
|
||||||
|
//
|
||||||
|
// # GraphQL scalars
|
||||||
|
//
|
||||||
|
// [MarshalUint64] and [UnmarshalUint64] provide JS-safe uint64 serialization for
|
||||||
|
// gqlgen without importing gqlgen directly.
|
||||||
|
//
|
||||||
|
// # 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. Setup returns an error if DebugLog is
|
||||||
|
// enabled without the matching blank import.
|
||||||
|
//
|
||||||
|
// # Dialect notes
|
||||||
|
//
|
||||||
|
// Use [Insert] with LastInsertId on MySQL; Postgres callers should use
|
||||||
|
// [InsertReturning]. [InsertReturning] and [UpdateReturning] require Jet
|
||||||
|
// RETURNING clauses.
|
||||||
|
package dbx
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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) {
|
||||||
|
return InsertContext(context.Background(), sqlo, stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertContext is the context-aware variant of [Insert].
|
||||||
|
func InsertContext(ctx context.Context, sqlo Executable, stmt Statement) (uint64, error) {
|
||||||
|
res, err := stmt.ExecContext(ctx, 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) {
|
||||||
|
return InsertReturningContext[T](context.Background(), sqlo, stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertReturningContext is the context-aware variant of [InsertReturning].
|
||||||
|
func InsertReturningContext[T any](ctx context.Context, sqlo Queryable, stmt Statement) (*T, error) {
|
||||||
|
return queryReturningContext[T](ctx, sqlo, stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update executes an update statement, returning an error if the update fails.
|
||||||
|
func Update(sqlo Executable, stmt Statement) error {
|
||||||
|
return UpdateContext(context.Background(), sqlo, stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateContext is the context-aware variant of [Update].
|
||||||
|
func UpdateContext(ctx context.Context, sqlo Executable, stmt Statement) error {
|
||||||
|
_, err := stmt.ExecContext(ctx, 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) {
|
||||||
|
return UpdateAffectedContext(context.Background(), sqlo, stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAffectedContext is the context-aware variant of [UpdateAffected].
|
||||||
|
func UpdateAffectedContext(ctx context.Context, sqlo Executable, stmt Statement) (int64, error) {
|
||||||
|
res, err := stmt.ExecContext(ctx, sqlo)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rowsAffected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOne executes an update statement and returns notFoundErr if zero rows
|
||||||
|
// were affected.
|
||||||
|
func UpdateOne(sqlo Executable, stmt Statement, notFoundErr error) error {
|
||||||
|
return UpdateOneContext(context.Background(), sqlo, stmt, notFoundErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOneContext is the context-aware variant of [UpdateOne].
|
||||||
|
func UpdateOneContext(ctx context.Context, sqlo Executable, stmt Statement, notFoundErr error) error {
|
||||||
|
n, err := UpdateAffectedContext(ctx, sqlo, stmt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return notFoundErr
|
||||||
|
}
|
||||||
|
return 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) {
|
||||||
|
return UpdateReturningContext[T](context.Background(), sqlo, stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateReturningContext is the context-aware variant of [UpdateReturning].
|
||||||
|
func UpdateReturningContext[T any](ctx context.Context, sqlo Queryable, stmt Statement) (*T, error) {
|
||||||
|
return queryReturningContext[T](ctx, sqlo, stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete executes a delete statement, returning an error if the delete fails.
|
||||||
|
func Delete(sqlo Executable, stmt Statement) error {
|
||||||
|
return DeleteContext(context.Background(), sqlo, stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteContext is the context-aware variant of [Delete].
|
||||||
|
func DeleteContext(ctx context.Context, sqlo Executable, stmt Statement) error {
|
||||||
|
_, err := stmt.ExecContext(ctx, sqlo)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAffected executes a delete statement and returns the number of rows
|
||||||
|
// affected and an error if any.
|
||||||
|
func DeleteAffected(sqlo Executable, stmt Statement) (int64, error) {
|
||||||
|
return DeleteAffectedContext(context.Background(), sqlo, stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAffectedContext is the context-aware variant of [DeleteAffected].
|
||||||
|
func DeleteAffectedContext(ctx context.Context, sqlo Executable, stmt Statement) (int64, error) {
|
||||||
|
res, err := stmt.ExecContext(ctx, sqlo)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rowsAffected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryReturningContext[T any](ctx context.Context, sqlo Queryable, stmt Statement) (*T, error) {
|
||||||
|
var result T
|
||||||
|
if err := stmt.QueryContext(ctx, sqlo, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInsert_ReturnsLastInsertID(t *testing.T) {
|
||||||
|
stmt := mockStatement{
|
||||||
|
execContextFn: func(_ context.Context) (sql.Result, error) {
|
||||||
|
return mockResult{lastInsertID: 42}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := Insert(mockExecutable{}, stmt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(42), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInsert_RejectsZeroID(t *testing.T) {
|
||||||
|
stmt := mockStatement{
|
||||||
|
execContextFn: func(_ context.Context) (sql.Result, error) {
|
||||||
|
return mockResult{lastInsertID: 0}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := Insert(mockExecutable{}, stmt)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInsertReturning_ReturnsRow(t *testing.T) {
|
||||||
|
stmt := mockStatement{
|
||||||
|
queryContextFn: func(_ context.Context, dest any) error {
|
||||||
|
ptr := dest.(*row)
|
||||||
|
ptr.ID = 7
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := InsertReturning[row](mockQueryable{}, stmt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
assert.Equal(t, 7, result.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAffected_ReturnsCount(t *testing.T) {
|
||||||
|
stmt := mockStatement{
|
||||||
|
execContextFn: func(_ context.Context) (sql.Result, error) {
|
||||||
|
return mockResult{rowsAffected: 3}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := UpdateAffected(mockExecutable{}, stmt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(3), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateOne_NotFound(t *testing.T) {
|
||||||
|
errNotFound := errors.New("not found")
|
||||||
|
stmt := mockStatement{
|
||||||
|
execContextFn: func(_ context.Context) (sql.Result, error) {
|
||||||
|
return mockResult{rowsAffected: 0}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := UpdateOne(mockExecutable{}, stmt, errNotFound)
|
||||||
|
assert.ErrorIs(t, err, errNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateOne_Success(t *testing.T) {
|
||||||
|
stmt := mockStatement{
|
||||||
|
execContextFn: func(_ context.Context) (sql.Result, error) {
|
||||||
|
return mockResult{rowsAffected: 1}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := UpdateOne(mockExecutable{}, stmt, errors.New("not found"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_Succeeds(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
stmt := mockStatement{
|
||||||
|
execContextFn: func(_ context.Context) (sql.Result, error) {
|
||||||
|
called = true
|
||||||
|
return mockResult{}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Delete(mockExecutable{}, stmt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, called)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAffected_ReturnsCount(t *testing.T) {
|
||||||
|
stmt := mockStatement{
|
||||||
|
execContextFn: func(_ context.Context) (sql.Result, error) {
|
||||||
|
return mockResult{rowsAffected: 2}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := DeleteAffected(mockExecutable{}, stmt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(2), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecContext_PropagatesError(t *testing.T) {
|
||||||
|
wantErr := errors.New("exec failed")
|
||||||
|
stmt := mockStatement{
|
||||||
|
execContextFn: func(_ context.Context) (sql.Result, error) {
|
||||||
|
return nil, wantErr
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := DeleteContext(context.Background(), mockExecutable{}, stmt)
|
||||||
|
assert.ErrorIs(t, err, wantErr)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -13,13 +13,20 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.73.4 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.53.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ gitea.auvem.com/go-toolkit/app v0.0.0-20250530181559-231561c92698/go.mod h1:a7EN
|
|||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/go-jet/jet/v2 v2.13.0 h1:DcD2IJRGos+4X40IQRV6S6q9onoOfZY/GPdvU6ImZcQ=
|
github.com/go-jet/jet/v2 v2.13.0 h1:DcD2IJRGos+4X40IQRV6S6q9onoOfZY/GPdvU6ImZcQ=
|
||||||
@@ -20,10 +22,14 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
|||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c=
|
github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c=
|
||||||
github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
|
github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
@@ -34,8 +40,18 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||||
|
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||||
|
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Uint64Marshaler wraps a uint64 for GraphQL JSON output as a string scalar.
|
||||||
|
type Uint64Marshaler uint64
|
||||||
|
|
||||||
|
// MarshalGQL writes the uint64 as a JSON string for gqlgen compatibility.
|
||||||
|
func (u Uint64Marshaler) MarshalGQL(w io.Writer) {
|
||||||
|
fmt.Fprintf(w, "\"%d\"", uint64(u))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalUint64 marshals a uint64 as a JSON string to avoid JavaScript
|
||||||
|
// precision loss for integers larger than 2^53-1.
|
||||||
|
func MarshalUint64(i uint64) Uint64Marshaler {
|
||||||
|
return Uint64Marshaler(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalUint64 unmarshals a uint64 from a JSON string or number.
|
||||||
|
func UnmarshalUint64(v any) (uint64, error) {
|
||||||
|
switch value := v.(type) {
|
||||||
|
case string:
|
||||||
|
if value == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseUint(value, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid uint64 string: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
case int:
|
||||||
|
if value < 0 {
|
||||||
|
return 0, fmt.Errorf("invalid negative uint64: %d", value)
|
||||||
|
}
|
||||||
|
return uint64(value), nil
|
||||||
|
case int64:
|
||||||
|
if value < 0 {
|
||||||
|
return 0, fmt.Errorf("invalid negative uint64: %d", value)
|
||||||
|
}
|
||||||
|
return uint64(value), nil
|
||||||
|
case float64:
|
||||||
|
if value < 0 || value != float64(uint64(value)) {
|
||||||
|
return 0, fmt.Errorf("invalid uint64 number: %v", value)
|
||||||
|
}
|
||||||
|
return uint64(value), nil
|
||||||
|
case uint64:
|
||||||
|
return value, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("invalid uint64 type: %T", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMarshalUint64(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
MarshalUint64(9007199254740993).MarshalGQL(&buf)
|
||||||
|
assert.Equal(t, `"9007199254740993"`, buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalUint64_String(t *testing.T) {
|
||||||
|
n, err := UnmarshalUint64("42")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(42), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalUint64_Number(t *testing.T) {
|
||||||
|
n, err := UnmarshalUint64(float64(42))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(42), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalUint64_RoundTripJSON(t *testing.T) {
|
||||||
|
const original uint64 = 9007199254740993
|
||||||
|
var buf bytes.Buffer
|
||||||
|
MarshalUint64(original).MarshalGQL(&buf)
|
||||||
|
|
||||||
|
var wire string
|
||||||
|
require.NoError(t, json.Unmarshal(buf.Bytes(), &wire))
|
||||||
|
|
||||||
|
got, err := UnmarshalUint64(wire)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, original, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalUint64_Invalid(t *testing.T) {
|
||||||
|
_, err := UnmarshalUint64(true)
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
_, err = UnmarshalUint64(float64(-1))
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package dbxshared
|
package dbxshared
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
type Logger interface {
|
type Logger interface {
|
||||||
InitLogger()
|
InitLogger()
|
||||||
}
|
}
|
||||||
@@ -20,11 +22,17 @@ func RegisterLogger(dialect dialectString, logger Logger) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InitLogger initializes the logger for a specific dialect.
|
// InitLogger initializes the logger for a specific dialect.
|
||||||
func InitLogger(dialect dialectString) {
|
func InitLogger(dialect dialectString) error {
|
||||||
dialectStr := dialect.String()
|
dialectStr := dialect.String()
|
||||||
logger, exists := loggerRegistry[dialectStr]
|
logger, exists := loggerRegistry[dialectStr]
|
||||||
if !exists {
|
if !exists {
|
||||||
panic("No logger registered for dialect: " + dialectStr)
|
return fmt.Errorf("no logger registered for dialect %q: blank-import dbxm (MySQL) or dbxp (Postgres)", dialectStr)
|
||||||
}
|
}
|
||||||
logger.InitLogger()
|
logger.InitLogger()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetLoggerRegistry clears registered loggers. It is intended for tests only.
|
||||||
|
func ResetLoggerRegistry() {
|
||||||
|
loggerRegistry = make(map[string]Logger)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package dbxshared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testDialect string
|
||||||
|
|
||||||
|
func (d testDialect) String() string { return string(d) }
|
||||||
|
|
||||||
|
type stubLogger struct{}
|
||||||
|
|
||||||
|
func (stubLogger) InitLogger() {}
|
||||||
|
|
||||||
|
func TestInitLogger_UnregisteredDialect(t *testing.T) {
|
||||||
|
ResetLoggerRegistry()
|
||||||
|
t.Cleanup(ResetLoggerRegistry)
|
||||||
|
|
||||||
|
err := InitLogger(testDialect("mysql"))
|
||||||
|
assert.ErrorContains(t, err, "blank-import dbxm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitLogger_RegisteredDialect(t *testing.T) {
|
||||||
|
ResetLoggerRegistry()
|
||||||
|
t.Cleanup(ResetLoggerRegistry)
|
||||||
|
|
||||||
|
RegisterLogger(testDialect("mysql"), stubLogger{})
|
||||||
|
err := InitLogger(testDialect("mysql"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContainsCol reports whether cols contains col.
|
||||||
|
func ContainsCol(cols ColumnList, col Column) bool {
|
||||||
|
return slices.Contains(cols, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalCols processes a list of columns and strips out any that implement any of
|
||||||
|
// ColumnTimestamp, ColumnTime, or ColumnDate.
|
||||||
|
func NormalCols(cols ...Column) ColumnList {
|
||||||
|
res := make(ColumnList, 0)
|
||||||
|
|
||||||
|
for _, col := range cols {
|
||||||
|
switch col.(type) {
|
||||||
|
case mysql.ColumnTimestamp,
|
||||||
|
mysql.ColumnTime,
|
||||||
|
mysql.ColumnDate:
|
||||||
|
default:
|
||||||
|
res = append(res, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/mysql"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContainsCol(t *testing.T) {
|
||||||
|
colA := mysql.StringColumn("a")
|
||||||
|
colB := mysql.StringColumn("b")
|
||||||
|
cols := ColumnList{colA, colB}
|
||||||
|
|
||||||
|
assert.True(t, ContainsCol(cols, colA))
|
||||||
|
assert.False(t, ContainsCol(cols, mysql.StringColumn("c")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalCols_ExcludesTimestamp(t *testing.T) {
|
||||||
|
name := mysql.StringColumn("name")
|
||||||
|
ts := mysql.TimestampColumn("updated_at")
|
||||||
|
cols := NormalCols(name, ts)
|
||||||
|
|
||||||
|
require.Len(t, cols, 1)
|
||||||
|
assert.Equal(t, name, cols[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockTxDB struct {
|
||||||
|
beginErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTxDB) Query(string, ...any) (*sql.Rows, error) { return nil, nil }
|
||||||
|
|
||||||
|
func (m *mockTxDB) QueryContext(context.Context, string, ...any) (*sql.Rows, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTxDB) Exec(string, ...any) (sql.Result, error) { return nil, nil }
|
||||||
|
|
||||||
|
func (m *mockTxDB) ExecContext(context.Context, string, ...any) (sql.Result, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTxDB) Begin() (*sql.Tx, error) {
|
||||||
|
if m.beginErr != nil {
|
||||||
|
return nil, m.beginErr
|
||||||
|
}
|
||||||
|
return &sql.Tx{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithTx_ReturnsFnError(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
wantErr := errors.New("fn failed")
|
||||||
|
err := WithTx(context.Background(), db, func(_ *sql.Tx) error {
|
||||||
|
return wantErr
|
||||||
|
})
|
||||||
|
assert.ErrorIs(t, err, wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithTx_CommitsOnSuccess(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
called := false
|
||||||
|
|
||||||
|
err := WithTx(context.Background(), db, func(_ *sql.Tx) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, called)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := sql.Open("sqlite", ":memory:")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithTx_CancelledContext(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := WithTx(ctx, &mockTxDB{}, func(_ *sql.Tx) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
assert.ErrorIs(t, err, context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithTx_BeginError(t *testing.T) {
|
||||||
|
wantErr := errors.New("begin failed")
|
||||||
|
err := WithTx(context.Background(), &mockTxDB{beginErr: wantErr}, func(_ *sql.Tx) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
assert.ErrorIs(t, err, wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ QueryExecTx = (*mockTxDB)(nil)
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExprValues converts a list of values to a list of 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) Expression) []Expression {
|
||||||
|
expressions := make([]Expression, len(values))
|
||||||
|
for i, v := range values {
|
||||||
|
expressions[i] = f(v)
|
||||||
|
}
|
||||||
|
return expressions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExprPtrs converts a list of pointers to Expression values, skipping nil
|
||||||
|
// entries. Function f receives the dereferenced value.
|
||||||
|
func ExprPtrs[T any](values []*T, f func(T) Expression) []Expression {
|
||||||
|
expressions := make([]Expression, 0, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
if v == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expressions = append(expressions, f(*v))
|
||||||
|
}
|
||||||
|
return expressions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExprStringers converts a list of fmt.Stringers to a list of Expression values.
|
||||||
|
func ExprStringers(values []fmt.Stringer) []Expression {
|
||||||
|
expressions := make([]Expression, len(values))
|
||||||
|
for i, v := range values {
|
||||||
|
expressions[i] = mysql.String(v.String())
|
||||||
|
}
|
||||||
|
return expressions
|
||||||
|
}
|
||||||
|
|
||||||
|
// StringKSUIDExpr returns a Jet expression for a string-encoded KSUID column.
|
||||||
|
func StringKSUIDExpr(id StringKSUID) Expression {
|
||||||
|
if id.IsNil() {
|
||||||
|
return mysql.StringExp(mysql.NULL)
|
||||||
|
}
|
||||||
|
return mysql.String(id.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// BinaryKSUIDExpr returns a Jet expression for a binary-encoded KSUID column.
|
||||||
|
func BinaryKSUIDExpr(id BinaryKSUID) Expression {
|
||||||
|
if id.IsNil() {
|
||||||
|
return mysql.StringExp(mysql.NULL)
|
||||||
|
}
|
||||||
|
return mysql.String(string(id.Bytes()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStringKSUIDExpr(t *testing.T) {
|
||||||
|
id := NewStringKSUID()
|
||||||
|
expr := StringKSUIDExpr(id)
|
||||||
|
require.NotNil(t, expr)
|
||||||
|
assert.Equal(t, expr, id.Expr())
|
||||||
|
|
||||||
|
nilExpr := StringKSUIDExpr(NilStringKSUID)
|
||||||
|
require.NotNil(t, nilExpr)
|
||||||
|
assert.Equal(t, nilExpr, NilStringKSUID.Expr())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBinaryKSUIDExpr(t *testing.T) {
|
||||||
|
id := NewBinaryKSUID()
|
||||||
|
expr := BinaryKSUIDExpr(id)
|
||||||
|
require.NotNil(t, expr)
|
||||||
|
assert.Equal(t, expr, id.Expr())
|
||||||
|
|
||||||
|
nilExpr := BinaryKSUIDExpr(NilBinaryKSUID)
|
||||||
|
require.NotNil(t, nilExpr)
|
||||||
|
assert.Equal(t, nilExpr, NilBinaryKSUID.Expr())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExprPtrs_StringKSUID(t *testing.T) {
|
||||||
|
a := NewStringKSUID()
|
||||||
|
b := NewStringKSUID()
|
||||||
|
exprs := ExprPtrs([]*StringKSUID{&a, nil, &b}, StringKSUIDExpr)
|
||||||
|
require.Len(t, exprs, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExprPtrs_BinaryKSUID(t *testing.T) {
|
||||||
|
a := NewBinaryKSUID()
|
||||||
|
exprs := ExprPtrs([]*BinaryKSUID{&a, nil}, BinaryKSUIDExpr)
|
||||||
|
require.Len(t, exprs, 1)
|
||||||
|
assert.NotNil(t, exprs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExprValues_StringKSUID(t *testing.T) {
|
||||||
|
a := NewStringKSUID()
|
||||||
|
b := NewStringKSUID()
|
||||||
|
exprs := ExprValues([]*StringKSUID{&a, &b}, func(id *StringKSUID) Expression {
|
||||||
|
return id.Expr()
|
||||||
|
})
|
||||||
|
require.Len(t, exprs, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExprValues_BinaryKSUID(t *testing.T) {
|
||||||
|
a := NewBinaryKSUID()
|
||||||
|
exprs := ExprValues([]BinaryKSUID{a}, BinaryKSUIDExpr)
|
||||||
|
require.Len(t, exprs, 1)
|
||||||
|
assert.NotNil(t, exprs[0])
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import "github.com/go-jet/jet/v2/mysql"
|
||||||
|
|
||||||
|
// Column is a dialect-neutral alias for Jet column expressions.
|
||||||
|
type Column = mysql.Column
|
||||||
|
|
||||||
|
// ColumnList is a dialect-neutral alias for Jet column lists.
|
||||||
|
type ColumnList = mysql.ColumnList
|
||||||
|
|
||||||
|
// Expression is a dialect-neutral alias for Jet SQL expressions.
|
||||||
|
type Expression = mysql.Expression
|
||||||
|
|
||||||
|
// BoolExpression is a dialect-neutral alias for Jet boolean expressions.
|
||||||
|
type BoolExpression = mysql.BoolExpression
|
||||||
|
|
||||||
|
// ReadableTable is a dialect-neutral alias for Jet readable tables.
|
||||||
|
type ReadableTable = mysql.ReadableTable
|
||||||
@@ -15,11 +15,6 @@ func NewJSONB(data map[string]any) JSONB {
|
|||||||
return JSONB(data)
|
return JSONB(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToMap converts the JSONB value to a map[string]any.
|
|
||||||
func (j JSONB) ToMap() map[string]any {
|
|
||||||
return map[string]any(j)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan implements the sql.Scanner interface. It supports converting from
|
// Scan implements the sql.Scanner interface. It supports converting from
|
||||||
// string, []byte, or nil into a JSONB value. Attempting to convert from
|
// string, []byte, or nil into a JSONB value. Attempting to convert from
|
||||||
// any other type will return an error.
|
// any other type will return an error.
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJSONB_ScanValue(t *testing.T) {
|
||||||
|
original := JSONB{"key": "value", "num": float64(1)}
|
||||||
|
|
||||||
|
val, err := original.Value()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var scanned JSONB
|
||||||
|
require.NoError(t, scanned.Scan(val))
|
||||||
|
assert.Equal(t, original["key"], scanned["key"])
|
||||||
|
|
||||||
|
require.NoError(t, scanned.Scan([]byte(`{"a":1}`)))
|
||||||
|
assert.Equal(t, float64(1), scanned["a"])
|
||||||
|
|
||||||
|
require.NoError(t, scanned.Scan(`{"b":2}`))
|
||||||
|
assert.Equal(t, float64(2), scanned["b"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONB_ScanNil(t *testing.T) {
|
||||||
|
j := JSONB{"x": 1}
|
||||||
|
require.NoError(t, j.Scan(nil))
|
||||||
|
assert.Nil(t, j)
|
||||||
|
|
||||||
|
val, err := JSONB(nil).Value()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONB_ScanUnsupportedType(t *testing.T) {
|
||||||
|
var j JSONB
|
||||||
|
err := j.Scan(123)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewJSONB(t *testing.T) {
|
||||||
|
data := map[string]any{"k": "v"}
|
||||||
|
j := NewJSONB(data)
|
||||||
|
assert.Equal(t, JSONB(data), j)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONB_String(t *testing.T) {
|
||||||
|
j := JSONB{"a": "b"}
|
||||||
|
assert.JSONEq(t, `{"a":"b"}`, j.String())
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/segmentio/ksuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BinaryKSUID wraps segmentio/ksuid for binary-column storage (BINARY(20), BYTEA).
|
||||||
|
// SQL Scan/Value use the raw 20-byte encoding. GraphQL transit uses a string
|
||||||
|
// scalar (base62 KSUID).
|
||||||
|
type BinaryKSUID struct {
|
||||||
|
ksuid.KSUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// NilBinaryKSUID is the zero/nil BinaryKSUID value.
|
||||||
|
var NilBinaryKSUID = BinaryKSUID{KSUID: ksuid.Nil}
|
||||||
|
|
||||||
|
// Equal reports whether two BinaryKSUID values represent the same identifier,
|
||||||
|
// treating two nil values as equal. Implements [ApplyInterface].
|
||||||
|
func (b BinaryKSUID) Equal(rh BinaryKSUID) bool {
|
||||||
|
return equalKSUID(b.KSUID, rh.KSUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsZero reports whether b is nil. Implements [ApplyInterface].
|
||||||
|
func (b BinaryKSUID) IsZero() bool {
|
||||||
|
return b.IsNil()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBinaryKSUID generates a new BinaryKSUID.
|
||||||
|
func NewBinaryKSUID() BinaryKSUID {
|
||||||
|
return BinaryKSUID{KSUID: ksuid.New()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBinaryKSUID parses a BinaryKSUID from its raw 20-byte form.
|
||||||
|
func ParseBinaryKSUID(b []byte) (BinaryKSUID, error) {
|
||||||
|
id, err := ksuid.FromBytes(b)
|
||||||
|
if err != nil {
|
||||||
|
return BinaryKSUID{}, err
|
||||||
|
}
|
||||||
|
return BinaryKSUID{KSUID: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AsStringKSUID returns a StringKSUID view of the same identifier.
|
||||||
|
func (b BinaryKSUID) AsStringKSUID() StringKSUID {
|
||||||
|
return StringKSUID{KSUID: b.KSUID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expr returns a Jet expression for this BinaryKSUID. See [BinaryKSUIDExpr].
|
||||||
|
func (b BinaryKSUID) Expr() Expression {
|
||||||
|
return BinaryKSUIDExpr(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan implements sql.Scanner for binary-backed KSUID columns.
|
||||||
|
func (b *BinaryKSUID) Scan(src any) error {
|
||||||
|
switch v := src.(type) {
|
||||||
|
case nil:
|
||||||
|
*b = BinaryKSUID{}
|
||||||
|
return nil
|
||||||
|
case []byte:
|
||||||
|
if len(v) == ksuidStringLength {
|
||||||
|
return fmt.Errorf("BinaryKSUID.Scan: string-encoded KSUID (%d bytes); use StringKSUID", len(v))
|
||||||
|
}
|
||||||
|
id, err := ksuid.FromBytes(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("BinaryKSUID.Scan: %w", err)
|
||||||
|
}
|
||||||
|
*b = BinaryKSUID{KSUID: id}
|
||||||
|
return nil
|
||||||
|
case string:
|
||||||
|
return fmt.Errorf("BinaryKSUID.Scan: string value %q; use StringKSUID or store raw bytes", v)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("BinaryKSUID.Scan: unable to scan type %T", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements driver.Valuer for binary-backed KSUID columns.
|
||||||
|
func (b BinaryKSUID) Value() (driver.Value, error) {
|
||||||
|
if b.IsNil() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return b.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalGQL implements the graphql.Unmarshaler interface.
|
||||||
|
func (b *BinaryKSUID) UnmarshalGQL(value any) error {
|
||||||
|
return unmarshalKSUIDFromGQL(value, b.UnmarshalText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalGQL implements the graphql.Marshaler interface.
|
||||||
|
func (b BinaryKSUID) MarshalGQL(w io.Writer) {
|
||||||
|
marshalKSUIDToGQL(w, b.KSUID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/segmentio/ksuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func unmarshalKSUIDFromGQL(value any, unmarshal func([]byte) error) error {
|
||||||
|
str, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("GraphQL failed to unmarshal KSUID value: %v", value)
|
||||||
|
}
|
||||||
|
return unmarshal([]byte(str))
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalKSUIDToGQL writes a JSON-encoded string scalar for gqlgen.
|
||||||
|
func marshalKSUIDToGQL(w io.Writer, id ksuid.KSUID) {
|
||||||
|
quoted := strconv.Quote(id.String())
|
||||||
|
if _, err := io.WriteString(w, quoted); err != nil {
|
||||||
|
panic(fmt.Errorf("GraphQL failed to write KSUID value: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ksuidBinaryLength = 20
|
||||||
|
ksuidStringLength = 27
|
||||||
|
)
|
||||||
|
|
||||||
|
func equalKSUID(lh, rh ksuid.KSUID) bool {
|
||||||
|
if lh.IsNil() && rh.IsNil() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return lh.String() == rh.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/segmentio/ksuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseStringKSUIDAny parses a StringKSUID from a string, byte slice, ksuid.KSUID,
|
||||||
|
// or nil. Accepts base62 string (27 bytes), raw binary (20 bytes), or hex-encoded
|
||||||
|
// forms (40 or 54 bytes).
|
||||||
|
func ParseStringKSUIDAny(src any) (StringKSUID, error) {
|
||||||
|
id, err := parseKSUIDAny(src)
|
||||||
|
if err != nil {
|
||||||
|
return NilStringKSUID, err
|
||||||
|
}
|
||||||
|
return StringKSUID{KSUID: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBinaryKSUIDAny parses a BinaryKSUID from the same accepted forms as
|
||||||
|
// [ParseStringKSUIDAny].
|
||||||
|
func ParseBinaryKSUIDAny(src any) (BinaryKSUID, error) {
|
||||||
|
id, err := parseKSUIDAny(src)
|
||||||
|
if err != nil {
|
||||||
|
return NilBinaryKSUID, err
|
||||||
|
}
|
||||||
|
return BinaryKSUID{KSUID: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKSUIDAny(src any) (ksuid.KSUID, error) {
|
||||||
|
switch v := src.(type) {
|
||||||
|
case ksuid.KSUID:
|
||||||
|
return v, nil
|
||||||
|
case StringKSUID:
|
||||||
|
return v.KSUID, nil
|
||||||
|
case BinaryKSUID:
|
||||||
|
return v.KSUID, nil
|
||||||
|
case string:
|
||||||
|
return parseKSUIDBytes([]byte(v))
|
||||||
|
case []byte:
|
||||||
|
return parseKSUIDBytes(v)
|
||||||
|
case nil:
|
||||||
|
return ksuid.Nil, nil
|
||||||
|
default:
|
||||||
|
return ksuid.Nil, fmt.Errorf("cannot parse KSUID from type %T", src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKSUIDBytes(src []byte) (ksuid.KSUID, error) {
|
||||||
|
if len(src) == 40 || len(src) == 54 {
|
||||||
|
decoded := make([]byte, hex.DecodedLen(len(src)))
|
||||||
|
if _, err := hex.Decode(decoded, src); err != nil {
|
||||||
|
return ksuid.Nil, err
|
||||||
|
}
|
||||||
|
src = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
switch len(src) {
|
||||||
|
case 0:
|
||||||
|
return ksuid.Nil, nil
|
||||||
|
case ksuidBinaryLength:
|
||||||
|
return ksuid.FromBytes(src)
|
||||||
|
case ksuidStringLength:
|
||||||
|
return ksuid.Parse(string(src))
|
||||||
|
default:
|
||||||
|
return ksuid.Nil, fmt.Errorf("cannot parse KSUID from byte slice of length %d", len(src))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/segmentio/ksuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseStringKSUIDAny(t *testing.T) {
|
||||||
|
id := ksuid.New()
|
||||||
|
stringForm := id.String()
|
||||||
|
byteForm := id.Bytes()
|
||||||
|
hexForm := hex.EncodeToString(byteForm)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
src any
|
||||||
|
}{
|
||||||
|
{"string", stringForm},
|
||||||
|
{"bytes string form", []byte(stringForm)},
|
||||||
|
{"bytes binary form", byteForm},
|
||||||
|
{"hex", []byte(hexForm)},
|
||||||
|
{"ksuid", id},
|
||||||
|
{"StringKSUID", StringKSUID{KSUID: id}},
|
||||||
|
{"nil", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := ParseStringKSUIDAny(tc.src)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tc.src == nil {
|
||||||
|
assert.True(t, got.IsZero())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Equal(t, id.String(), got.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBinaryKSUIDAny(t *testing.T) {
|
||||||
|
id := ksuid.New()
|
||||||
|
got, err := ParseBinaryKSUIDAny(id.Bytes())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, id.Bytes(), got.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStringKSUIDAny_InvalidType(t *testing.T) {
|
||||||
|
_, err := ParseStringKSUIDAny(123)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/segmentio/ksuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StringKSUID wraps segmentio/ksuid for text-column storage (VARCHAR, TEXT).
|
||||||
|
// SQL Scan/Value use the base62 string encoding. GraphQL transit uses a string
|
||||||
|
// scalar (base62 KSUID).
|
||||||
|
type StringKSUID struct {
|
||||||
|
ksuid.KSUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// NilStringKSUID is the zero/nil StringKSUID value.
|
||||||
|
var NilStringKSUID = StringKSUID{KSUID: ksuid.Nil}
|
||||||
|
|
||||||
|
// Equal reports whether two StringKSUID values represent the same identifier,
|
||||||
|
// treating two nil values as equal. Implements [ApplyInterface].
|
||||||
|
func (s StringKSUID) Equal(rh StringKSUID) bool {
|
||||||
|
return equalKSUID(s.KSUID, rh.KSUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsZero reports whether s is nil. Implements [ApplyInterface].
|
||||||
|
func (s StringKSUID) IsZero() bool {
|
||||||
|
return s.IsNil()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStringKSUID generates a new StringKSUID.
|
||||||
|
func NewStringKSUID() StringKSUID {
|
||||||
|
return StringKSUID{KSUID: ksuid.New()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseStringKSUID parses a StringKSUID from its base62 string form.
|
||||||
|
func ParseStringKSUID(s string) (StringKSUID, error) {
|
||||||
|
id, err := ksuid.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return StringKSUID{}, err
|
||||||
|
}
|
||||||
|
return StringKSUID{KSUID: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AsBinaryKSUID returns a BinaryKSUID view of the same identifier.
|
||||||
|
func (s StringKSUID) AsBinaryKSUID() BinaryKSUID {
|
||||||
|
return BinaryKSUID{KSUID: s.KSUID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expr returns a Jet expression for this StringKSUID. See [StringKSUIDExpr].
|
||||||
|
func (s StringKSUID) Expr() Expression {
|
||||||
|
return StringKSUIDExpr(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan implements sql.Scanner for string-backed KSUID columns.
|
||||||
|
func (s *StringKSUID) Scan(src any) error {
|
||||||
|
switch v := src.(type) {
|
||||||
|
case nil:
|
||||||
|
*s = StringKSUID{}
|
||||||
|
return nil
|
||||||
|
case string:
|
||||||
|
id, err := ksuid.Parse(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("StringKSUID.Scan: %w", err)
|
||||||
|
}
|
||||||
|
*s = StringKSUID{KSUID: id}
|
||||||
|
return nil
|
||||||
|
case []byte:
|
||||||
|
if len(v) == ksuidBinaryLength {
|
||||||
|
return fmt.Errorf("StringKSUID.Scan: binary KSUID payload (%d bytes); use BinaryKSUID", len(v))
|
||||||
|
}
|
||||||
|
id, err := ksuid.Parse(string(v))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("StringKSUID.Scan: %w", err)
|
||||||
|
}
|
||||||
|
*s = StringKSUID{KSUID: id}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("StringKSUID.Scan: unable to scan type %T", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements driver.Valuer for string-backed KSUID columns.
|
||||||
|
func (s StringKSUID) Value() (driver.Value, error) {
|
||||||
|
if s.IsNil() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return s.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalGQL implements the graphql.Unmarshaler interface.
|
||||||
|
func (s *StringKSUID) UnmarshalGQL(value any) error {
|
||||||
|
return unmarshalKSUIDFromGQL(value, s.UnmarshalText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalGQL implements the graphql.Marshaler interface.
|
||||||
|
func (s StringKSUID) MarshalGQL(w io.Writer) {
|
||||||
|
marshalKSUIDToGQL(w, s.KSUID)
|
||||||
|
}
|
||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/mysql"
|
||||||
|
"github.com/segmentio/ksuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStringKSUID_ScanValue(t *testing.T) {
|
||||||
|
id := NewStringKSUID()
|
||||||
|
require.False(t, id.IsNil())
|
||||||
|
|
||||||
|
val, err := id.Value()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, id.String(), val)
|
||||||
|
|
||||||
|
var scanned StringKSUID
|
||||||
|
require.NoError(t, scanned.Scan(id.String()))
|
||||||
|
assert.Equal(t, id, scanned)
|
||||||
|
|
||||||
|
require.NoError(t, scanned.Scan([]byte(id.String())))
|
||||||
|
assert.Equal(t, id, scanned)
|
||||||
|
|
||||||
|
var nilKSUID StringKSUID
|
||||||
|
val, err = nilKSUID.Value()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, val)
|
||||||
|
|
||||||
|
require.NoError(t, scanned.Scan(nil))
|
||||||
|
assert.True(t, scanned.IsNil())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringKSUID_ScanRejectsBinary(t *testing.T) {
|
||||||
|
var s StringKSUID
|
||||||
|
err := s.Scan(idBytes(t))
|
||||||
|
assert.ErrorContains(t, err, "BinaryKSUID")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringKSUID_Conversion(t *testing.T) {
|
||||||
|
s := NewStringKSUID()
|
||||||
|
b := s.AsBinaryKSUID()
|
||||||
|
assert.Equal(t, s.String(), b.String())
|
||||||
|
assert.Equal(t, s.Bytes(), b.Bytes())
|
||||||
|
assert.Equal(t, s, b.AsStringKSUID())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringKSUID_GQL(t *testing.T) {
|
||||||
|
id := NewStringKSUID()
|
||||||
|
wire := assertKSUIDGQLMarshal(t, id)
|
||||||
|
|
||||||
|
var parsed StringKSUID
|
||||||
|
require.NoError(t, parsed.UnmarshalGQL(wire))
|
||||||
|
assert.Equal(t, id, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBinaryKSUID_GQL(t *testing.T) {
|
||||||
|
id := NewBinaryKSUID()
|
||||||
|
wire := assertKSUIDGQLMarshal(t, id)
|
||||||
|
|
||||||
|
var parsed BinaryKSUID
|
||||||
|
require.NoError(t, parsed.UnmarshalGQL(wire))
|
||||||
|
assert.Equal(t, id, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertKSUIDGQLMarshal(t *testing.T, id interface{ String() string; MarshalGQL(w io.Writer) }) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
id.MarshalGQL(&buf)
|
||||||
|
|
||||||
|
var wire string
|
||||||
|
require.NoError(t, json.Unmarshal(buf.Bytes(), &wire))
|
||||||
|
assert.Equal(t, id.String(), wire)
|
||||||
|
return wire
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBinaryKSUID_ScanValue(t *testing.T) {
|
||||||
|
id := NewBinaryKSUID()
|
||||||
|
require.False(t, id.IsNil())
|
||||||
|
|
||||||
|
val, err := id.Value()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, id.Bytes(), val)
|
||||||
|
|
||||||
|
var scanned BinaryKSUID
|
||||||
|
require.NoError(t, scanned.Scan(id.Bytes()))
|
||||||
|
assert.Equal(t, id, scanned)
|
||||||
|
|
||||||
|
var nilKSUID BinaryKSUID
|
||||||
|
val, err = nilKSUID.Value()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, val)
|
||||||
|
|
||||||
|
require.NoError(t, scanned.Scan(nil))
|
||||||
|
assert.True(t, scanned.IsNil())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBinaryKSUID_ScanRejectsStringEncoding(t *testing.T) {
|
||||||
|
s := NewStringKSUID()
|
||||||
|
var b BinaryKSUID
|
||||||
|
err := b.Scan([]byte(s.String()))
|
||||||
|
assert.ErrorContains(t, err, "StringKSUID")
|
||||||
|
|
||||||
|
err = b.Scan(s.String())
|
||||||
|
assert.ErrorContains(t, err, "StringKSUID")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBinaryKSUID_Conversion(t *testing.T) {
|
||||||
|
b := NewBinaryKSUID()
|
||||||
|
s := b.AsStringKSUID()
|
||||||
|
assert.Equal(t, b.String(), s.String())
|
||||||
|
assert.Equal(t, b, s.AsBinaryKSUID())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestStringKSUID_EqualIsZero(t *testing.T) {
|
||||||
|
id := NewStringKSUID()
|
||||||
|
assert.True(t, NilStringKSUID.Equal(NilStringKSUID))
|
||||||
|
assert.True(t, id.Equal(id))
|
||||||
|
assert.False(t, id.Equal(NilStringKSUID))
|
||||||
|
assert.True(t, NilStringKSUID.IsZero())
|
||||||
|
assert.False(t, id.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBinaryKSUID_EqualIsZero(t *testing.T) {
|
||||||
|
id := NewBinaryKSUID()
|
||||||
|
assert.True(t, NilBinaryKSUID.Equal(NilBinaryKSUID))
|
||||||
|
assert.True(t, id.Equal(id))
|
||||||
|
assert.False(t, id.Equal(NilBinaryKSUID))
|
||||||
|
assert.True(t, NilBinaryKSUID.IsZero())
|
||||||
|
assert.False(t, id.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringKSUID_ApplyInterfacePtr(t *testing.T) {
|
||||||
|
targetCol := mysql.StringColumn("id")
|
||||||
|
modified := make(ColumnList, 0)
|
||||||
|
current := Ptr(NewStringKSUID())
|
||||||
|
updated := ApplyInterfacePtr(current, Ptr(NewStringKSUID()), &modified, targetCol)
|
||||||
|
require.NotNil(t, updated)
|
||||||
|
assert.Len(t, modified, 1)
|
||||||
|
assert.False(t, current.Equal(*updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStringKSUID(t *testing.T) {
|
||||||
|
id := ksuid.New()
|
||||||
|
parsed, err := ParseStringKSUID(id.String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, id.String(), parsed.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBinaryKSUID(t *testing.T) {
|
||||||
|
id := ksuid.New()
|
||||||
|
parsed, err := ParseBinaryKSUID(id.Bytes())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, id.Bytes(), parsed.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func idBytes(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
return ksuid.New().Bytes()
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// CurrentDialect returns the SQL dialect configured by [ModuleDB].
|
||||||
|
func CurrentDialect() Dialect {
|
||||||
|
return state.dialect
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
if err := dbxshared.InitLogger(state.dialect); err != nil {
|
||||||
|
m.Logger().Error("Couldn't initialize Jet query debug logger", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.auvem.com/go-toolkit/dbx/internal/dbxshared"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDestName_MissingFieldWithoutModule(t *testing.T) {
|
||||||
|
type Foo struct {
|
||||||
|
Bar int
|
||||||
|
}
|
||||||
|
|
||||||
|
name := DestName(Foo{}, "Missing")
|
||||||
|
assert.Empty(t, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDestName_ValidPath(t *testing.T) {
|
||||||
|
type Inner struct {
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
type Foo struct {
|
||||||
|
Inner Inner
|
||||||
|
}
|
||||||
|
|
||||||
|
name := DestName(Foo{}, "Inner", "Value")
|
||||||
|
assert.Equal(t, "Foo.Inner.Value", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDialect_AfterModuleDB(t *testing.T) {
|
||||||
|
if dbxshared.DBModule != nil {
|
||||||
|
t.Skip("database module already initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = ModuleDB(DialectPostgres, &DBConfig{
|
||||||
|
User: "user",
|
||||||
|
Password: "pass",
|
||||||
|
URI: "localhost:5432",
|
||||||
|
Name: "test",
|
||||||
|
MaxConn: 1,
|
||||||
|
}, false)
|
||||||
|
|
||||||
|
assert.Equal(t, DialectPostgres, CurrentDialect())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPtr_Val(t *testing.T) {
|
||||||
|
assert.Nil(t, Ptr(""))
|
||||||
|
assert.Equal(t, Ptr("hi"), Ptr("hi"))
|
||||||
|
assert.Equal(t, "", Val((*string)(nil)))
|
||||||
|
assert.Equal(t, "hi", Val(Ptr("hi")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringToFilter(t *testing.T) {
|
||||||
|
assert.Equal(t, "%hello%world%", StringToFilter("hello world"))
|
||||||
|
assert.Equal(t, "%test%", StringToFilter("%test%"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrimPtr(t *testing.T) {
|
||||||
|
s := " hello "
|
||||||
|
assert.Equal(t, "hello", *TrimPtr(&s))
|
||||||
|
assert.Nil(t, TrimPtr(nil))
|
||||||
|
assert.Nil(t, TrimPtrToNil(Ptr(" ")))
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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) {
|
||||||
|
return FetchContext[T](context.Background(), sqlo, stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchContext is the context-aware variant of [Fetch].
|
||||||
|
func FetchContext[T any](ctx context.Context, sqlo Queryable, stmt Statement) ([]*T, error) {
|
||||||
|
var result []*T
|
||||||
|
if err := stmt.QueryContext(ctx, 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) {
|
||||||
|
return MustFetchContext[T](context.Background(), sqlo, stmt, notFoundErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustFetchContext is the context-aware variant of [MustFetch].
|
||||||
|
func MustFetchContext[T any](ctx context.Context, sqlo Queryable, stmt Statement, notFoundErr error) ([]*T, error) {
|
||||||
|
result, err := FetchContext[T](ctx, 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) {
|
||||||
|
return FetchOneContext[T](context.Background(), sqlo, stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchOneContext is the context-aware variant of [FetchOne].
|
||||||
|
func FetchOneContext[T any](ctx context.Context, sqlo Queryable, stmt Statement) (*T, error) {
|
||||||
|
result, err := FetchContext[T](ctx, 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) {
|
||||||
|
return MustFetchOneContext[T](context.Background(), sqlo, stmt, notFoundErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustFetchOneContext is the context-aware variant of [MustFetchOne].
|
||||||
|
func MustFetchOneContext[T any](ctx context.Context, sqlo Queryable, stmt Statement, notFoundErr error) (*T, error) {
|
||||||
|
result, err := MustFetchContext[T](ctx, sqlo, stmt, notFoundErr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query executes a Jet statement and scans the result into dest.
|
||||||
|
func Query(sqlo Queryable, stmt Statement, dest any) error {
|
||||||
|
return QueryContext(context.Background(), sqlo, stmt, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryContext is the context-aware variant of [Query].
|
||||||
|
func QueryContext(ctx context.Context, sqlo Queryable, stmt Statement, dest any) error {
|
||||||
|
return stmt.QueryContext(ctx, sqlo, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustQuery executes a Jet statement and scans the result into dest. If the
|
||||||
|
// query returns no rows, notFoundErr is returned instead of [ErrNoRows].
|
||||||
|
func MustQuery(sqlo Queryable, stmt Statement, dest any, notFoundErr error) error {
|
||||||
|
return MustQueryContext(context.Background(), sqlo, stmt, dest, notFoundErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustQueryContext is the context-aware variant of [MustQuery].
|
||||||
|
func MustQueryContext(ctx context.Context, sqlo Queryable, stmt Statement, dest any, notFoundErr error) error {
|
||||||
|
err := stmt.QueryContext(ctx, sqlo, dest)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNoRows) {
|
||||||
|
return notFoundErr
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetch_EmptyOnNoRows(t *testing.T) {
|
||||||
|
stmt := mockStatement{
|
||||||
|
queryContextFn: func(_ context.Context, dest any) error {
|
||||||
|
return ErrNoRows
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Fetch[row](mockQueryable{}, stmt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetch_ReturnsRows(t *testing.T) {
|
||||||
|
stmt := mockStatement{
|
||||||
|
queryContextFn: func(_ context.Context, dest any) error {
|
||||||
|
ptr := dest.(*[]*row)
|
||||||
|
*ptr = []*row{{ID: 1}, {ID: 2}}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Fetch[row](mockQueryable{}, stmt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, result, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustFetch_NotFound(t *testing.T) {
|
||||||
|
errNotFound := errors.New("not found")
|
||||||
|
stmt := mockStatement{
|
||||||
|
queryContextFn: func(_ context.Context, _ any) error {
|
||||||
|
return ErrNoRows
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := MustFetch[row](mockQueryable{}, stmt, errNotFound)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.ErrorIs(t, err, errNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchContext_PropagatesContext(t *testing.T) {
|
||||||
|
ctx := context.WithValue(context.Background(), testContextKey{}, "ok")
|
||||||
|
var gotCtx context.Context
|
||||||
|
stmt := mockStatement{
|
||||||
|
queryContextFn: func(c context.Context, dest any) error {
|
||||||
|
gotCtx = c
|
||||||
|
ptr := dest.(*[]*row)
|
||||||
|
*ptr = []*row{{ID: 3}}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := FetchContext[row](ctx, mockQueryable{}, stmt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ctx, gotCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
type testContextKey struct{}
|
||||||
|
|
||||||
|
func TestFetchOne_NilWhenEmpty(t *testing.T) {
|
||||||
|
stmt := mockStatement{
|
||||||
|
queryContextFn: func(_ context.Context, _ any) error {
|
||||||
|
return ErrNoRows
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := FetchOne[row](mockQueryable{}, stmt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustFetchOne_NotFound(t *testing.T) {
|
||||||
|
errNotFound := errors.New("not found")
|
||||||
|
stmt := mockStatement{
|
||||||
|
queryContextFn: func(_ context.Context, _ any) error {
|
||||||
|
return ErrNoRows
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := MustFetchOne[row](mockQueryable{}, stmt, errNotFound)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.ErrorIs(t, err, errNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuery_ScansDest(t *testing.T) {
|
||||||
|
stmt := mockStatement{
|
||||||
|
queryContextFn: func(_ context.Context, dest any) error {
|
||||||
|
ptr := dest.(*row)
|
||||||
|
ptr.ID = 5
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var result row
|
||||||
|
err := Query(mockQueryable{}, stmt, &result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 5, result.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustQuery_NotFound(t *testing.T) {
|
||||||
|
errNotFound := errors.New("not found")
|
||||||
|
stmt := mockStatement{
|
||||||
|
queryContextFn: func(_ context.Context, _ any) error {
|
||||||
|
return ErrNoRows
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var result row
|
||||||
|
err := MustQuery(mockQueryable{}, stmt, &result, errNotFound)
|
||||||
|
assert.ErrorIs(t, err, errNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustQueryContext_PropagatesContext(t *testing.T) {
|
||||||
|
ctx := context.WithValue(context.Background(), testContextKey{}, "ok")
|
||||||
|
var gotCtx context.Context
|
||||||
|
stmt := mockStatement{
|
||||||
|
queryContextFn: func(c context.Context, dest any) error {
|
||||||
|
gotCtx = c
|
||||||
|
return ErrNoRows
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = MustQueryContext(ctx, mockQueryable{}, stmt, &row{}, errors.New("nf"))
|
||||||
|
assert.Equal(t, ctx, gotCtx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockStatement struct {
|
||||||
|
queryContextFn func(ctx context.Context, dest any) error
|
||||||
|
execContextFn func(ctx context.Context) (sql.Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockStatement) Query(db qrm.Queryable, dest any) error {
|
||||||
|
return m.QueryContext(context.Background(), db, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockStatement) QueryContext(ctx context.Context, db qrm.Queryable, dest any) error {
|
||||||
|
if m.queryContextFn != nil {
|
||||||
|
return m.queryContextFn(ctx, dest)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockStatement) Exec(db qrm.Executable) (sql.Result, error) {
|
||||||
|
return m.ExecContext(context.Background(), db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockStatement) ExecContext(ctx context.Context, db qrm.Executable) (sql.Result, error) {
|
||||||
|
if m.execContextFn != nil {
|
||||||
|
return m.execContextFn(ctx)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockQueryable struct{}
|
||||||
|
|
||||||
|
func (mockQueryable) Query(string, ...any) (*sql.Rows, error) { return nil, nil }
|
||||||
|
|
||||||
|
func (mockQueryable) QueryContext(context.Context, string, ...any) (*sql.Rows, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockExecutable struct{}
|
||||||
|
|
||||||
|
func (mockExecutable) Exec(string, ...any) (sql.Result, error) { return nil, nil }
|
||||||
|
|
||||||
|
func (mockExecutable) ExecContext(context.Context, string, ...any) (sql.Result, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockResult struct {
|
||||||
|
lastInsertID int64
|
||||||
|
rowsAffected int64
|
||||||
|
lastInsertErr error
|
||||||
|
rowsAffectedErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r mockResult) LastInsertId() (int64, error) {
|
||||||
|
return r.lastInsertID, r.lastInsertErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r mockResult) RowsAffected() (int64, error) {
|
||||||
|
return r.rowsAffected, r.rowsAffectedErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
ID int
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Statement = mockStatement{}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WithTx begins a transaction on db, runs fn, and commits on success.
|
||||||
|
// The transaction is rolled back if fn returns an error or commit fails.
|
||||||
|
func WithTx(ctx context.Context, db QueryExecTx, fn func(tx *sql.Tx) error) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fn(tx); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTxValue begins a transaction on db, runs fn, and commits on success.
|
||||||
|
// Returns the value from fn or zero and an error if begin, fn, or commit fails.
|
||||||
|
// The transaction is rolled back if fn returns an error.
|
||||||
|
func WithTxValue[T any](ctx context.Context, db QueryExecTx, fn func(tx QueryExec) (T, error)) (T, error) {
|
||||||
|
var zero T
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := fn(tx)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package dbx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWithTxValue_ReturnsResult(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
result, err := WithTxValue(context.Background(), db, func(tx QueryExec) (int, error) {
|
||||||
|
return 42, nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 42, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithTxValue_RollbackOnError(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
fail := errors.New("fail")
|
||||||
|
|
||||||
|
_, err := WithTxValue(context.Background(), db, func(tx QueryExec) (int, error) {
|
||||||
|
return 0, fail
|
||||||
|
})
|
||||||
|
assert.ErrorIs(t, err, fail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithTxValue_CancelledContext(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := WithTxValue(ctx, openTestDB(t), func(tx QueryExec) (int, error) {
|
||||||
|
return 1, nil
|
||||||
|
})
|
||||||
|
assert.ErrorIs(t, err, context.Canceled)
|
||||||
|
}
|
||||||
-277
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package dbx
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
|
|
||||||
"github.com/segmentio/ksuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// UUID is a wrapper around ksuid.KSUID that implements the
|
|
||||||
// graphql.Unmarshaler and graphql.Marshaler interfaces for use in GraphQL APIs.
|
|
||||||
// It also provides additional convenience functions such as ParseUUID and NewUUID.
|
|
||||||
type UUID struct {
|
|
||||||
ksuid.KSUID
|
|
||||||
}
|
|
||||||
|
|
||||||
type uuidTransport struct {
|
|
||||||
UUIDStr string `json:"uuid_str"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generates a new, wrapped KSUID. In the strange case that random bytes can't be read, it will panic.
|
|
||||||
func NewUUID() UUID {
|
|
||||||
return UUID{KSUID: ksuid.New()}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseUUID parses a UUID from a string. If the string is not a valid UUID, it will return an error.
|
|
||||||
func ParseUUID(s string) (UUID, error) {
|
|
||||||
ksuid, err := ksuid.Parse(s)
|
|
||||||
if err != nil {
|
|
||||||
return UUID{}, err
|
|
||||||
}
|
|
||||||
return UUID{KSUID: ksuid}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalGQL implements the graphql.Unmarshaler interface
|
|
||||||
func (u *UUID) UnmarshalGQL(value interface{}) error {
|
|
||||||
slog.Debug("uuid unmarshaling from gql", "val", value)
|
|
||||||
str, ok := value.(string)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("GraphQL failed to unmarshal UUID value: %v", value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := u.UnmarshalText([]byte(str)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalGQL implements the graphql.Marshaler interface
|
|
||||||
func (u UUID) MarshalGQL(w io.Writer) {
|
|
||||||
transport := uuidTransport{UUIDStr: u.String()}
|
|
||||||
json, err := json.Marshal(transport)
|
|
||||||
if err != nil {
|
|
||||||
panic(fmt.Errorf("GraphQL failed to JSON-marshal UUID value: %s", err))
|
|
||||||
}
|
|
||||||
slog.Debug("uuid marshaling to gql", "uuid", u.String(), "json", string(json))
|
|
||||||
_, err = w.Write(json)
|
|
||||||
if err != nil {
|
|
||||||
panic(fmt.Errorf("GraphQL failed to write UUID value: %s", string(json)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user