Skip to content

Commit 9225f3e

Browse files
committed
Simplify data models further
.Save() - variadic arg instead of .Save() and .SaveAll() .List() - renamed from .GetAll() .Get() - renamed from .GetOne() .LockForUpdates() - renamed from .LockForUpdate() .LockForUpdate() - renamed from .LockOneForUpdate()
1 parent bacaeaa commit 9225f3e

2 files changed

Lines changed: 47 additions & 36 deletions

File tree

table.go

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,14 @@ type hasSetDeletedAt interface {
2929
SetDeletedAt(time.Time)
3030
}
3131

32-
// Save inserts or updates a record. Auto-detects insert vs update by ID.
33-
func (t *Table[T, PT, IDT]) Save(ctx context.Context, record PT) error {
32+
// Save inserts or updates given records. Auto-detects insert vs update by ID.
33+
func (t *Table[T, PT, IDT]) Save(ctx context.Context, records ...PT) error {
34+
if len(records) != 1 { // Handles 0 records too.
35+
return t.saveAll(ctx, records)
36+
}
37+
38+
record := records[0]
39+
3440
if err := record.Validate(); err != nil {
3541
return err //nolint:wrapcheck
3642
}
@@ -59,8 +65,9 @@ func (t *Table[T, PT, IDT]) Save(ctx context.Context, record PT) error {
5965
return nil
6066
}
6167

62-
// SaveAll saves multiple records sequentially.
63-
func (t *Table[T, PT, IDT]) SaveAll(ctx context.Context, records []PT) error {
68+
// saveAll saves multiple records sequentially.
69+
// TODO: This can be likely optimized to use a batch insert.
70+
func (t *Table[T, PT, IDT]) saveAll(ctx context.Context, records []PT) error {
6471
for i := range records {
6572
if err := t.Save(ctx, records[i]); err != nil {
6673
return err
@@ -70,56 +77,56 @@ func (t *Table[T, PT, IDT]) SaveAll(ctx context.Context, records []PT) error {
7077
return nil
7178
}
7279

73-
// GetOne returns the first record matching the condition.
74-
func (t *Table[T, PT, IDT]) GetOne(ctx context.Context, cond sq.Sqlizer, orderBy []string) (PT, error) {
80+
// Get returns the first record matching the condition.
81+
func (t *Table[T, PT, IDT]) Get(ctx context.Context, where sq.Sqlizer, orderBy []string) (PT, error) {
7582
if len(orderBy) == 0 {
7683
orderBy = []string{t.IDColumn}
7784
}
7885

79-
dest := new(T)
86+
record := new(T)
8087

8188
q := t.SQL.
8289
Select("*").
8390
From(t.Name).
84-
Where(cond).
91+
Where(where).
8592
Limit(1).
8693
OrderBy(orderBy...)
8794

88-
if err := t.Query.GetOne(ctx, q, dest); err != nil {
95+
if err := t.Query.GetOne(ctx, q, record); err != nil {
8996
return nil, err
9097
}
9198

92-
return dest, nil
99+
return record, nil
93100
}
94101

95-
// GetAll returns all records matching the condition.
96-
func (t *Table[T, PT, IDT]) GetAll(ctx context.Context, cond sq.Sqlizer, orderBy []string) ([]PT, error) {
102+
// List returns all records matching the condition.
103+
func (t *Table[T, PT, IDT]) List(ctx context.Context, where sq.Sqlizer, orderBy []string) ([]PT, error) {
97104
if len(orderBy) == 0 {
98105
orderBy = []string{t.IDColumn}
99106
}
100107

101108
q := t.SQL.
102109
Select("*").
103110
From(t.Name).
104-
Where(cond).
111+
Where(where).
105112
OrderBy(orderBy...)
106113

107-
var dest []PT
108-
if err := t.Query.GetAll(ctx, q, &dest); err != nil {
114+
var records []PT
115+
if err := t.Query.GetAll(ctx, q, &records); err != nil {
109116
return nil, err
110117
}
111118

112-
return dest, nil
119+
return records, nil
113120
}
114121

115122
// GetByID returns a record by its ID.
116123
func (t *Table[T, PT, IDT]) GetByID(ctx context.Context, id IDT) (PT, error) {
117-
return t.GetOne(ctx, sq.Eq{t.IDColumn: id}, []string{t.IDColumn})
124+
return t.Get(ctx, sq.Eq{t.IDColumn: id}, []string{t.IDColumn})
118125
}
119126

120127
// GetByIDs returns records by their IDs.
121128
func (t *Table[T, PT, IDT]) GetByIDs(ctx context.Context, ids []IDT) ([]PT, error) {
122-
return t.GetAll(ctx, sq.Eq{t.IDColumn: ids}, nil)
129+
return t.List(ctx, sq.Eq{t.IDColumn: ids}, nil)
123130
}
124131

125132
// Count returns the number of matching records.
@@ -173,31 +180,31 @@ func (t *Table[T, PT, IDT]) WithTx(tx pgx.Tx) *Table[T, PT, IDT] {
173180
}
174181
}
175182

176-
// LockForUpdate locks and processes records using PostgreSQL's FOR UPDATE SKIP LOCKED pattern
183+
// LockForUpdates locks and processes records using PostgreSQL's FOR UPDATE SKIP LOCKED pattern
177184
// for safe concurrent processing. Each record is processed exactly once across multiple workers.
178185
// Records are automatically updated after updateFn() completes. Keep updateFn() fast to avoid
179186
// holding the transaction. For long-running work, update status to "processing" and return early,
180187
// then process asynchronously. Use defer LockOneForUpdate() to update status to "completed" or "failed".
181-
func (t *Table[T, PT, IDT]) LockForUpdate(ctx context.Context, cond sq.Sqlizer, orderBy []string, limit uint64, updateFn func(records []PT)) error {
188+
func (t *Table[T, PT, IDT]) LockForUpdates(ctx context.Context, where sq.Sqlizer, orderBy []string, limit uint64, updateFn func(records []PT)) error {
182189
// Check if we're already in a transaction
183190
if t.DB.Query.Tx != nil {
184-
return t.lockForUpdateWithTx(ctx, t.DB.Query.Tx, cond, orderBy, limit, updateFn)
191+
return t.lockForUpdatesWithTx(ctx, t.DB.Query.Tx, where, orderBy, limit, updateFn)
185192
}
186193

187194
return pgx.BeginFunc(ctx, t.DB.Conn, func(pgTx pgx.Tx) error {
188-
return t.lockForUpdateWithTx(ctx, pgTx, cond, orderBy, limit, updateFn)
195+
return t.lockForUpdatesWithTx(ctx, pgTx, where, orderBy, limit, updateFn)
189196
})
190197
}
191198

192-
func (t *Table[T, PT, IDT]) lockForUpdateWithTx(ctx context.Context, pgTx pgx.Tx, cond sq.Sqlizer, orderBy []string, limit uint64, updateFn func(records []PT)) error {
199+
func (t *Table[T, PT, IDT]) lockForUpdatesWithTx(ctx context.Context, pgTx pgx.Tx, where sq.Sqlizer, orderBy []string, limit uint64, updateFn func(records []PT)) error {
193200
if len(orderBy) == 0 {
194201
orderBy = []string{t.IDColumn}
195202
}
196203

197204
q := t.SQL.
198205
Select("*").
199206
From(t.Name).
200-
Where(cond).
207+
Where(where).
201208
OrderBy(orderBy...).
202209
Limit(limit).
203210
Suffix("FOR UPDATE SKIP LOCKED")
@@ -221,17 +228,17 @@ func (t *Table[T, PT, IDT]) lockForUpdateWithTx(ctx context.Context, pgTx pgx.Tx
221228
return nil
222229
}
223230

224-
// LockOneForUpdate locks and processes one record using PostgreSQL's FOR UPDATE SKIP LOCKED pattern
231+
// LockForUpdate locks and processes one record using PostgreSQL's FOR UPDATE SKIP LOCKED pattern
225232
// for safe concurrent processing. The record is processed exactly once across multiple workers.
226233
// The record is automatically updated after updateFn() completes. Keep updateFn() fast to avoid
227234
// holding the transaction. For long-running work, update status to "processing" and return early,
228-
// then process asynchronously. Use defer LockOneForUpdate() to update status to "completed" or "failed".
235+
// then process asynchronously. Use defer LockForUpdate() to update status to "completed" or "failed".
229236
//
230237
// Returns ErrNoRows if no matching records are available for locking.
231-
func (t *Table[T, PT, IDT]) LockOneForUpdate(ctx context.Context, cond sq.Sqlizer, orderBy []string, updateFn func(record PT)) error {
238+
func (t *Table[T, PT, IDT]) LockForUpdate(ctx context.Context, where sq.Sqlizer, orderBy []string, updateFn func(record PT)) error {
232239
var noRows bool
233240

234-
err := t.LockForUpdate(ctx, cond, orderBy, 1, func(records []PT) {
241+
err := t.LockForUpdates(ctx, where, orderBy, 1, func(records []PT) {
235242
if len(records) > 0 {
236243
updateFn(records[0])
237244
} else {

tests/table_test.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package pgkit_test
33
import (
44
"context"
55
"fmt"
6+
"log"
67
"slices"
78
"sync"
89
"testing"
@@ -75,8 +76,8 @@ func TestTable(t *testing.T) {
7576
}
7677

7778
// Save articles (3x insert).
78-
err = tx.Articles.SaveAll(ctx, articles)
79-
require.NoError(t, err, "SaveAll failed")
79+
err = tx.Articles.Save(ctx, articles...)
80+
require.NoError(t, err, "Save failed")
8081

8182
for _, article := range articles {
8283
require.NotZero(t, article.ID, "ID should be set")
@@ -88,8 +89,8 @@ func TestTable(t *testing.T) {
8889

8990
// Save articles (3x update, 1x insert).
9091
articles = append(articles, &Article{Author: "Fourth", AccountID: account.ID})
91-
err = tx.Articles.SaveAll(ctx, articles)
92-
require.NoError(t, err, "SaveAll failed")
92+
err = tx.Articles.Save(ctx, articles...)
93+
require.NoError(t, err, "Save failed")
9394

9495
for _, article := range articles {
9596
require.NotZero(t, article.ID, "ID should be set")
@@ -107,7 +108,7 @@ func TestTable(t *testing.T) {
107108
require.Equal(t, article.AccountID, articleCheck.AccountID, "Article AccountID should match")
108109
require.Equal(t, article.CreatedAt, articleCheck.CreatedAt, "Article CreatedAt should match")
109110
//require.Equal(t, article.UpdatedAt, articleCheck.UpdatedAt, "Article UpdatedAt should match")
110-
//require.NotEqual(t, article.UpdatedAt, articleCheck.UpdatedAt, "Article UpdatedAt shouldn't match") // The .SaveAll() aboe updates the timestamp.
111+
//require.NotEqual(t, article.UpdatedAt, articleCheck.UpdatedAt, "Article UpdatedAt shouldn't match") // The .Save() aboe updates the timestamp.
111112
require.Equal(t, article.DeletedAt, articleCheck.DeletedAt, "Article DeletedAt should match")
112113
}
113114

@@ -180,7 +181,7 @@ func TestLockForUpdate(t *testing.T) {
180181
Status: ReviewStatusPending,
181182
}
182183
}
183-
err = db.Reviews.SaveAll(ctx, reviews)
184+
err = db.Reviews.Save(ctx, reviews...)
184185
require.NoError(t, err, "create review")
185186

186187
cond := sq.Eq{
@@ -197,7 +198,7 @@ func TestLockForUpdate(t *testing.T) {
197198
go func() {
198199
defer wg.Done()
199200

200-
err := db.Reviews.LockForUpdate(ctx, cond, orderBy, 10, func(reviews []*Review) {
201+
err := db.Reviews.LockForUpdates(ctx, cond, orderBy, 10, func(reviews []*Review) {
201202
now := time.Now().UTC()
202203
for i, review := range reviews {
203204
review.Status = ReviewStatusProcessing
@@ -225,5 +226,8 @@ func TestLockForUpdate(t *testing.T) {
225226
func processReviewAsynchronously(ctx context.Context, db *Database, review *Review) {
226227
time.Sleep(1 * time.Second)
227228
review.Status = ReviewStatusApproved
228-
db.Reviews.Save(ctx, review)
229+
err := db.Reviews.Save(ctx, review)
230+
if err != nil {
231+
log.Printf("failed to save review: %v", err)
232+
}
229233
}

0 commit comments

Comments
 (0)