diff --git a/CHANGELOG.md b/CHANGELOG.md index d8eea3b..baa00c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Added - `StringKSUID.Equal` / `IsZero`, `BinaryKSUID.Equal` / `IsZero`, and nil sentinel vars for [ApplyInterfacePtr]. +- `Query`, `MustQuery`, and `UpdateOne` helpers (+ Context variants). - 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 8bf0378..0fe6c17 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ For Postgres, use `dbx.DialectPostgres` and blank-import `dbxp` instead of `dbxm | Area | Functions | |------|-----------| -| Query | `Fetch`, `MustFetch`, `FetchOne`, `MustFetchOne` (+ `*Context` variants) | -| Mutations | `Insert`, `InsertReturning`, `Update`, `UpdateAffected`, `UpdateReturning`, `Delete`, `DeleteAffected` (+ `*Context` variants) | +| Query | `Fetch`, `MustFetch`, `FetchOne`, `MustFetchOne`, `Query`, `MustQuery` (+ `*Context` variants) | +| Mutations | `Insert`, `InsertReturning`, `Update`, `UpdateAffected`, `UpdateOne`, `UpdateReturning`, `Delete`, `DeleteAffected` (+ `*Context` variants) | | Transactions | `WithTx` | | Columns | `NormalCols`, `ContainsCol`, `ExprValues`, `ExprStringers` | | Partial update | `ApplyPtr`, `ApplyComplexPtr`, `ApplyInterfacePtr`, `ApplyVal` | diff --git a/doc.go b/doc.go index a9ae93d..d20711c 100644 --- a/doc.go +++ b/doc.go @@ -8,8 +8,9 @@ // // # Query and mutation helpers // -// [Fetch], [FetchOne], [Insert], [Update], [Delete], and their Must* and Context -// variants wrap Jet [Statement] execution with consistent error semantics. +// [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. // // # Jet column utilities diff --git a/exec.go b/exec.go index c93ee57..caa44a5 100644 --- a/exec.go +++ b/exec.go @@ -74,6 +74,24 @@ func UpdateAffectedContext(ctx context.Context, sqlo Executable, stmt Statement) 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. diff --git a/exec_test.go b/exec_test.go index abd9343..f6d6838 100644 --- a/exec_test.go +++ b/exec_test.go @@ -60,6 +60,29 @@ func TestUpdateAffected_ReturnsCount(t *testing.T) { 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{ diff --git a/query.go b/query.go index 73c076b..68f65f7 100644 --- a/query.go +++ b/query.go @@ -70,3 +70,31 @@ func MustFetchOneContext[T any](ctx context.Context, sqlo Queryable, stmt Statem } 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 +} diff --git a/query_test.go b/query_test.go index 4aef167..3de39c0 100644 --- a/query_test.go +++ b/query_test.go @@ -91,3 +91,45 @@ func TestMustFetchOne_NotFound(t *testing.T) { 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) +}