Skip to content

Commit b6660ec

Browse files
committed
Add common functions for repositories (pgx and sql)
1 parent a22f0ce commit b6660ec

24 files changed

Lines changed: 1121 additions & 183 deletions

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
test:
2727
strategy:
2828
matrix:
29-
go-version: [ '1.20' ]
29+
go-version: [ '1.20', '1.21' ]
3030
platform: [ 'ubuntu-latest' ]
3131
runs-on: ${{ matrix.platform }}
3232
steps:

README.md

Lines changed: 43 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ This is a code generator to generate a bunch of structs and functions to impleme
1414

1515
* Selecting JSON from the database allows for nested data and does most of the work that an ORM does on the read side. It can be used to flexibly load related data in an eager way (see [example](./example/pgx/repository/repository_project.go)).
1616
* Querying the database without too much abstraction gives a lot of flexibility - where needed.
17+
Construct is paired with [github.com/networkteam/qrb](https://github.com/networkteam/qrb) as a PostgreSQL focused query builder that supports querying JSON.
1718
* Not using the model itself for writes reduces a lot of complexity around dirty checking and other ORM features.
1819
It works well in an [CQRS](https://martinfowler.com/bliki/CQRS.html) architecture where commands and queries are separated.
1920
* Database migrations often need more thought and are outside the scope of this tool.
@@ -113,112 +114,70 @@ Construct will automatically generate identifier expressions of fields (read col
113114
and a default `json_build_object` expression to select a model via JSON. This can be further modified to add additional properties.
114115

115116
*repository/customer_repository.go*
117+
116118
```go
117119
package repository
118120

119121
import (
120-
"context"
122+
"context"
121123

122-
"github.com/gofrs/uuid"
123-
"github.com/jackc/pgx/v5"
124-
"github.com/networkteam/qrb"
125-
"github.com/networkteam/qrb/builder"
126-
"github.com/networkteam/qrb/fn"
127-
"github.com/networkteam/qrb/qrbpgx"
124+
"github.com/gofrs/uuid"
125+
"github.com/jackc/pgx/v5"
126+
"github.com/networkteam/construct/v2/constructpgx"
127+
"github.com/networkteam/qrb"
128+
"github.com/networkteam/qrb/builder"
129+
"github.com/networkteam/qrb/fn"
130+
"github.com/networkteam/qrb/qrbpgx"
128131

129-
".../myproject/model"
132+
".../myproject/model"
130133
)
131134

132135
func FindCustomerByID(ctx context.Context, executor qrbpgx.Executor, id uuid.UUID) (model.Customer, error) {
133-
q := qrb.
134-
Select(customerJson()).
135-
// A schema type for read columns (and the table) is generated as qrb identifier expressions by construct
136-
From(customer).
137-
LeftJoin(project).On(project.CustomerID.Eq(customer.ID)).
138-
Where(customer.ID.Eq(qrb.Arg(id))).
139-
GroupBy(customer.ID)
140-
141-
row, err := qrbpgx.Build(q).WithExecutor(executor).QueryRow(ctx)
142-
if err != nil {
143-
return result, err
144-
}
145-
return pgxScanRow[model.Customer](row)
136+
q := qrb.
137+
Select(customerJson()).
138+
// A schema type for read columns (and the table) is generated as qrb identifier expressions by construct
139+
From(customer).
140+
LeftJoin(project).On(project.CustomerID.Eq(customer.ID)).
141+
Where(customer.ID.Eq(qrb.Arg(id))).
142+
GroupBy(customer.ID)
143+
144+
row, err := qrbpgx.Build(q).WithExecutor(executor).QueryRow(ctx)
145+
if err != nil {
146+
return result, err
147+
}
148+
return constructpgx.ScanRow[model.Customer](row)
146149
}
147150

148151
// CustomerChangeSet is generated by construct for handling partially filled models
149152
func InsertCustomer(ctx context.Context, executor qrbpgx.Executor, changeSet CustomerChangeSet) error {
150-
q := qrb.
151-
InsertInto(customer).
152-
// toMap is generated by construct
153-
SetMap(changeSet.toMap())
153+
q := qrb.
154+
InsertInto(customer).
155+
// toMap is generated by construct
156+
SetMap(changeSet.toMap())
154157

155-
_, err := qrbpgx.Build(q).WithExecutor(executor).Exec(ctx)
156-
return err
158+
_, err := qrbpgx.Build(q).WithExecutor(executor).Exec(ctx)
159+
return err
157160
}
158161

159162
func UpdateCustomer(ctx context.Context, executor qrbpgx.Executor, id uuid.UUID, changeSet CustomerChangeSet) error {
160-
q := qrb.
161-
Update(customer).
162-
Where(customer.ID.Eq(qrb.Arg(id))).
163-
SetMap(changeSet.toMap())
163+
q := qrb.
164+
Update(customer).
165+
Where(customer.ID.Eq(qrb.Arg(id))).
166+
SetMap(changeSet.toMap())
164167

165-
res, err := qrbpgx.Build(q).WithExecutor(executor).Exec(ctx)
166-
if err != nil {
167-
return err
168-
}
168+
res, err := qrbpgx.Build(q).WithExecutor(executor).Exec(ctx)
169+
if err != nil {
170+
return err
171+
}
169172

170-
return assertRowsAffected(res, "update", 1)
173+
return constructpgx.AssertRowsAffected(res, "update", 1)
171174
}
172175

173176
func customerJson() builder.JsonBuildObjectBuilder {
174-
// customerDefaultJson is generated by construct and is a JsonBuildObjectBuilder that can be further modified (immutable)
175-
return customerDefaultJson.
176-
// It's easy to set additional properties
177-
Prop("ProjectCount", qrb.Count(project.ID))
178-
}
179-
```
180-
181-
These are common functions that can be shared by all repository implementations:
182-
183-
*repository/commong.go*
184-
```go
185-
package repository
186-
187-
import (
188-
"fmt"
189-
190-
"github.com/jackc/pgx/v5"
191-
"github.com/jackc/pgx/v5/pgconn"
192-
193-
"github.com/networkteam/construct/v2"
194-
)
195-
196-
func pgxCollectRow[T any](row pgx.CollectableRow) (T, error) {
197-
return pgxScanRow[T](row)
198-
}
199-
200-
func pgxScanRow[T any](row pgx.Row) (T, error) {
201-
var result T
202-
err := row.Scan(&result)
203-
if err != nil {
204-
return result, fmt.Errorf("scanning row: %w", pgxToConstructErr(err))
205-
}
206-
return result, nil
207-
}
208-
209-
func pgxToConstructErr(err error) error {
210-
if err == pgx.ErrNoRows {
211-
return construct.ErrNotFound
212-
}
213-
return err
214-
}
215-
216-
func assertRowsAffected(res pgconn.CommandTag, op string, numberOfRows int64) error {
217-
rowsAffected := res.RowsAffected()
218-
if rowsAffected != numberOfRows {
219-
return fmt.Errorf("%s affected %d rows, but expected exactly %d", op, rowsAffected, numberOfRows)
220-
}
221-
return nil
177+
// customerDefaultJson is generated by construct and is a JsonBuildObjectBuilder that can be further modified (immutable)
178+
return customerDefaultJson.
179+
// It's easy to set additional properties
180+
Prop("ProjectCount", qrb.Count(project.ID))
222181
}
223182
```
224183

constructpgx/constructpgx.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package constructpgx
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
7+
"github.com/jackc/pgx/v5"
8+
"github.com/jackc/pgx/v5/pgconn"
9+
10+
"github.com/networkteam/construct/v2"
11+
)
12+
13+
// CollectRows collects all rows to the given target type from a ExecutiveQueryBuilder.Query result.
14+
// It is based on pgx.CollectRows but accepts an additional error for easier use.
15+
func CollectRows[T any](rows pgx.Rows, err error) ([]T, error) {
16+
if err != nil {
17+
return nil, err
18+
}
19+
20+
defer rows.Close()
21+
22+
slice := []T{}
23+
24+
for rows.Next() {
25+
value, err := scanRow[T](rows)
26+
if err != nil {
27+
return nil, err
28+
}
29+
slice = append(slice, value)
30+
}
31+
32+
if err := rows.Err(); err != nil {
33+
return nil, err
34+
}
35+
36+
return slice, nil
37+
}
38+
39+
func pgxToConstructErr(err error) error {
40+
if errors.Is(err, pgx.ErrNoRows) {
41+
return construct.ErrNotFound
42+
}
43+
return err
44+
}
45+
46+
// ScanRow scans a single row to the given target type from a ExecutiveQueryBuilder.QueryRow result.
47+
func ScanRow[T any](row pgx.Row, err error) (T, error) {
48+
var result T
49+
if err != nil {
50+
return result, pgxToConstructErr(err)
51+
}
52+
return scanRow[T](row)
53+
}
54+
55+
func scanRow[T any](row pgx.Row) (T, error) {
56+
var result T
57+
err := row.Scan(&result)
58+
if err != nil {
59+
return result, fmt.Errorf("scanning row: %w", pgxToConstructErr(err))
60+
}
61+
return result, nil
62+
}
63+
64+
// AssertRowsAffected checks if the given result affected exactly the expected number of rows.
65+
func AssertRowsAffected(result pgconn.CommandTag, operation string, expectedRows int) error {
66+
actualRows := result.RowsAffected()
67+
if actualRows != int64(expectedRows) {
68+
return fmt.Errorf("%s affected %d rows, but expected exactly %d", operation, actualRows, expectedRows)
69+
}
70+
71+
return nil
72+
73+
}

constructpgx/constructpgx_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package constructpgx_test
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/jackc/pgx/v5"
8+
"github.com/jackc/pgx/v5/pgconn"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/networkteam/construct/v2"
13+
"github.com/networkteam/construct/v2/constructpgx"
14+
)
15+
16+
type user struct {
17+
ID int
18+
Name string
19+
}
20+
21+
type mockRow struct {
22+
scanUser *user
23+
scanErr error
24+
}
25+
26+
func (r *mockRow) Scan(dest ...any) error {
27+
if r.scanErr != nil {
28+
return r.scanErr
29+
}
30+
31+
if len(dest) != 1 {
32+
return errors.New("mockRow.Scan: dest must have length 1")
33+
}
34+
35+
if r.scanUser == nil {
36+
return errors.New("mockRow.Scan: scanUser is nil")
37+
}
38+
39+
*dest[0].(*user) = *r.scanUser
40+
41+
return nil
42+
}
43+
44+
func TestScanRow(t *testing.T) {
45+
t.Run("scans row without error", func(t *testing.T) {
46+
row := mockRow{
47+
scanUser: &user{ID: 1, Name: "test"},
48+
}
49+
record, err := constructpgx.ScanRow[user](&row, nil)
50+
require.NoError(t, err)
51+
52+
assert.Equal(t, user{ID: 1, Name: "test"}, record)
53+
})
54+
55+
t.Run("returns QueryRow error", func(t *testing.T) {
56+
row := mockRow{}
57+
queryRowErr := errors.New("some query error")
58+
_, err := constructpgx.ScanRow[user](&row, queryRowErr)
59+
require.ErrorIs(t, err, queryRowErr)
60+
})
61+
62+
t.Run("returns Scan error", func(t *testing.T) {
63+
scanErr := errors.New("some scan error")
64+
row := mockRow{
65+
scanErr: scanErr,
66+
}
67+
_, err := constructpgx.ScanRow[user](&row, nil)
68+
require.ErrorIs(t, err, scanErr)
69+
})
70+
71+
t.Run("converts Scan err to ErrNotFound", func(t *testing.T) {
72+
scanErr := pgx.ErrNoRows
73+
row := mockRow{
74+
scanErr: scanErr,
75+
}
76+
_, err := constructpgx.ScanRow[user](&row, nil)
77+
require.ErrorIs(t, err, construct.ErrNotFound)
78+
})
79+
80+
t.Run("converts QueryRow err to ErrNotFound", func(t *testing.T) {
81+
// Note: the underlying error usually only occurs during Scan, but we want to make sure it works here as well.
82+
queryRowErr := pgx.ErrNoRows
83+
row := mockRow{}
84+
_, err := constructpgx.ScanRow[user](&row, queryRowErr)
85+
require.ErrorIs(t, err, construct.ErrNotFound)
86+
})
87+
}
88+
89+
func TestAssertRowsAffected(t *testing.T) {
90+
t.Run("returns nil if rows affected matches expected", func(t *testing.T) {
91+
err := constructpgx.AssertRowsAffected(pgconn.NewCommandTag("UPDATE 2"), "update", 2)
92+
require.NoError(t, err)
93+
})
94+
95+
t.Run("returns error if rows affected does not match expected", func(t *testing.T) {
96+
err := constructpgx.AssertRowsAffected(pgconn.NewCommandTag("UPDATE 2"), "update", 1)
97+
require.Error(t, err)
98+
})
99+
}

0 commit comments

Comments
 (0)