diff --git a/CHANGELOG.md b/CHANGELOG.md index baa00c0..7d9b0ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - `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. - 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`. diff --git a/README.md b/README.md index 0fe6c17..2c7843c 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ For Postgres, use `dbx.DialectPostgres` and blank-import `dbxp` instead of `dbxm |------|-----------| | Query | `Fetch`, `MustFetch`, `FetchOne`, `MustFetchOne`, `Query`, `MustQuery` (+ `*Context` variants) | | Mutations | `Insert`, `InsertReturning`, `Update`, `UpdateAffected`, `UpdateOne`, `UpdateReturning`, `Delete`, `DeleteAffected` (+ `*Context` variants) | -| Transactions | `WithTx` | +| Transactions | `WithTx`, `WithTxValue` | | Columns | `NormalCols`, `ContainsCol`, `ExprValues`, `ExprStringers` | | Partial update | `ApplyPtr`, `ApplyComplexPtr`, `ApplyInterfacePtr`, `ApplyVal` | | Pointers | `Ptr`, `Val`, `NowPtr`, `TrimPtr`, `TrimPtrToNil`, `IsZero` | diff --git a/doc.go b/doc.go index d20711c..1050b11 100644 --- a/doc.go +++ b/doc.go @@ -11,7 +11,7 @@ // [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] runs a function inside a SQL transaction. +// [WithTx] and [WithTxValue] run functions inside SQL transactions. // // # Jet column utilities // diff --git a/tx.go b/tx.go index ec5dbc1..95cf33e 100644 --- a/tx.go +++ b/tx.go @@ -24,3 +24,30 @@ func WithTx(ctx context.Context, db QueryExecTx, fn func(tx *sql.Tx) error) erro 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 +} diff --git a/tx_test.go b/tx_test.go new file mode 100644 index 0000000..96b1ea3 --- /dev/null +++ b/tx_test.go @@ -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) +}