feat(query): add Query, MustQuery, and UpdateOne helpers

Adds scan-into-dest query helpers and update-one-row semantics with
Context variants, tests, and documentation updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 18:24:29 -07:00
parent cc44931ea9
commit fbb9c74aab
7 changed files with 117 additions and 4 deletions
+1
View File
@@ -13,6 +13,7 @@
### Added ### Added
- `StringKSUID.Equal` / `IsZero`, `BinaryKSUID.Equal` / `IsZero`, and nil sentinel vars for [ApplyInterfacePtr]. - `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. - Context-aware variants for all query and mutation helpers.
- `Delete`, `DeleteAffected`, `WithTx`, `ContainsCol`, and `CurrentDialect()`. - `Delete`, `DeleteAffected`, `WithTx`, `ContainsCol`, and `CurrentDialect()`.
- Package documentation (`doc.go`), expanded README, and subpackage docs for `dbxm` / `dbxp`. - Package documentation (`doc.go`), expanded README, and subpackage docs for `dbxm` / `dbxp`.
+2 -2
View File
@@ -42,8 +42,8 @@ For Postgres, use `dbx.DialectPostgres` and blank-import `dbxp` instead of `dbxm
| Area | Functions | | Area | Functions |
|------|-----------| |------|-----------|
| Query | `Fetch`, `MustFetch`, `FetchOne`, `MustFetchOne` (+ `*Context` variants) | | Query | `Fetch`, `MustFetch`, `FetchOne`, `MustFetchOne`, `Query`, `MustQuery` (+ `*Context` variants) |
| Mutations | `Insert`, `InsertReturning`, `Update`, `UpdateAffected`, `UpdateReturning`, `Delete`, `DeleteAffected` (+ `*Context` variants) | | Mutations | `Insert`, `InsertReturning`, `Update`, `UpdateAffected`, `UpdateOne`, `UpdateReturning`, `Delete`, `DeleteAffected` (+ `*Context` variants) |
| Transactions | `WithTx` | | Transactions | `WithTx` |
| Columns | `NormalCols`, `ContainsCol`, `ExprValues`, `ExprStringers` | | Columns | `NormalCols`, `ContainsCol`, `ExprValues`, `ExprStringers` |
| Partial update | `ApplyPtr`, `ApplyComplexPtr`, `ApplyInterfacePtr`, `ApplyVal` | | Partial update | `ApplyPtr`, `ApplyComplexPtr`, `ApplyInterfacePtr`, `ApplyVal` |
+3 -2
View File
@@ -8,8 +8,9 @@
// //
// # Query and mutation helpers // # Query and mutation helpers
// //
// [Fetch], [FetchOne], [Insert], [Update], [Delete], and their Must* and Context // [Fetch], [FetchOne], [Query], [Insert], [Update], [Delete], and their Must*
// variants wrap Jet [Statement] execution with consistent error semantics. // 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] runs a function inside a SQL transaction.
// //
// # Jet column utilities // # Jet column utilities
+18
View File
@@ -74,6 +74,24 @@ func UpdateAffectedContext(ctx context.Context, sqlo Executable, stmt Statement)
return rowsAffected, nil 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. // UpdateReturning executes an update statement that returns the updated row.
// The statement MUST be a Jet UpdateStatement with a RETURNING clause. Returns // 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. // the updated row object T or an error if the update fails or no rows are returned.
+23
View File
@@ -60,6 +60,29 @@ func TestUpdateAffected_ReturnsCount(t *testing.T) {
assert.Equal(t, int64(3), n) 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) { func TestDelete_Succeeds(t *testing.T) {
called := false called := false
stmt := mockStatement{ stmt := mockStatement{
+28
View File
@@ -70,3 +70,31 @@ func MustFetchOneContext[T any](ctx context.Context, sqlo Queryable, stmt Statem
} }
return result[0], nil 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
}
+42
View File
@@ -91,3 +91,45 @@ func TestMustFetchOne_NotFound(t *testing.T) {
assert.Nil(t, result) assert.Nil(t, result)
assert.ErrorIs(t, err, errNotFound) 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)
}