Skip to content

Commit 2fa3605

Browse files
committed
feat: add RestoreByID and export timestamp interfaces
- Add RestoreByID method that clears DeletedAt by passing zero time - Export HasSetCreatedAt, HasSetUpdatedAt, HasSetDeletedAt interfaces with godoc explaining the contract for each lifecycle hook - Update SetDeletedAt implementations to treat zero time as restore (nil)
1 parent f74541e commit 2fa3605

3 files changed

Lines changed: 86 additions & 24 deletions

File tree

table.go

Lines changed: 59 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,34 @@ type Table[T any, P Record[T, I], I ID] struct {
3030
Paginator Paginator[P]
3131
}
3232

33-
// helpers for setting timestamp fields
34-
type (
35-
hasSetCreatedAt interface {
36-
SetCreatedAt(time.Time)
37-
}
38-
hasSetUpdatedAt interface {
39-
SetUpdatedAt(time.Time)
40-
}
41-
hasSetDeletedAt interface {
42-
SetDeletedAt(time.Time)
43-
}
44-
)
33+
// HasSetCreatedAt is implemented by records that track creation time.
34+
// Insert will automatically call SetCreatedAt with the current UTC time.
35+
type HasSetCreatedAt interface {
36+
SetCreatedAt(time.Time)
37+
}
38+
39+
// HasSetUpdatedAt is implemented by records that track update time.
40+
// Insert, Update, and Save will automatically call SetUpdatedAt with the current UTC time.
41+
type HasSetUpdatedAt interface {
42+
SetUpdatedAt(time.Time)
43+
}
44+
45+
// HasSetDeletedAt is implemented by records that support soft delete.
46+
// DeleteByID will call SetDeletedAt with the current UTC time to soft-delete,
47+
// and RestoreByID will call SetDeletedAt with a zero time.Time{} to restore.
48+
//
49+
// Implementations should treat a zero time as a restore (clear the timestamp):
50+
//
51+
// func (r *MyRecord) SetDeletedAt(t time.Time) {
52+
// if t.IsZero() {
53+
// r.DeletedAt = nil // restore: clear the timestamp
54+
// return
55+
// }
56+
// r.DeletedAt = &t // soft delete: set the timestamp
57+
// }
58+
type HasSetDeletedAt interface {
59+
SetDeletedAt(time.Time)
60+
}
4561

4662
// Insert inserts one or more records. Sets CreatedAt and UpdatedAt timestamps if available.
4763
// Records are returned with their generated fields populated via RETURNING *.
@@ -66,10 +82,10 @@ func (t *Table[T, P, I]) insertOne(ctx context.Context, record P) error {
6682
}
6783

6884
now := time.Now().UTC()
69-
if row, ok := any(record).(hasSetCreatedAt); ok {
85+
if row, ok := any(record).(HasSetCreatedAt); ok {
7086
row.SetCreatedAt(now)
7187
}
72-
if row, ok := any(record).(hasSetUpdatedAt); ok {
88+
if row, ok := any(record).(HasSetUpdatedAt); ok {
7389
row.SetUpdatedAt(now)
7490
}
7591

@@ -97,10 +113,10 @@ func (t *Table[T, P, I]) insertAll(ctx context.Context, records []P) error {
97113
return fmt.Errorf("validate record: %w", err)
98114
}
99115

100-
if row, ok := any(r).(hasSetCreatedAt); ok {
116+
if row, ok := any(r).(HasSetCreatedAt); ok {
101117
row.SetCreatedAt(now)
102118
}
103-
if row, ok := any(r).(hasSetUpdatedAt); ok {
119+
if row, ok := any(r).(HasSetUpdatedAt); ok {
104120
row.SetUpdatedAt(now)
105121
}
106122
}
@@ -152,7 +168,7 @@ func (t *Table[T, P, I]) updateOne(ctx context.Context, record P) error {
152168
return fmt.Errorf("validate record: %w", err)
153169
}
154170

155-
if row, ok := any(record).(hasSetUpdatedAt); ok {
171+
if row, ok := any(record).(HasSetUpdatedAt); ok {
156172
row.SetUpdatedAt(time.Now().UTC())
157173
}
158174

@@ -183,7 +199,7 @@ func (t *Table[T, P, I]) updateAll(ctx context.Context, records []P) error {
183199
return fmt.Errorf("validate record: %w", err)
184200
}
185201

186-
if row, ok := any(r).(hasSetUpdatedAt); ok {
202+
if row, ok := any(r).(HasSetUpdatedAt); ok {
187203
row.SetUpdatedAt(now)
188204
}
189205

@@ -223,7 +239,7 @@ func (t *Table[T, P, I]) saveOne(ctx context.Context, record P) error {
223239
return fmt.Errorf("validate record: %w", err)
224240
}
225241

226-
if row, ok := any(record).(hasSetUpdatedAt); ok {
242+
if row, ok := any(record).(HasSetUpdatedAt); ok {
227243
row.SetUpdatedAt(time.Now().UTC())
228244
}
229245

@@ -270,13 +286,13 @@ func (t *Table[T, P, I]) saveAll(ctx context.Context, records []P) error {
270286
return fmt.Errorf("validate record: %w", err)
271287
}
272288

273-
if row, ok := any(r).(hasSetUpdatedAt); ok {
289+
if row, ok := any(r).(HasSetUpdatedAt); ok {
274290
row.SetUpdatedAt(now)
275291
}
276292

277293
var zero I
278294
if r.GetID() == zero {
279-
if row, ok := any(r).(hasSetCreatedAt); ok {
295+
if row, ok := any(r).(HasSetCreatedAt); ok {
280296
row.SetCreatedAt(now)
281297
}
282298

@@ -432,7 +448,7 @@ func (t *Table[T, P, I]) DeleteByID(ctx context.Context, id I) error {
432448
}
433449

434450
// Soft delete.
435-
if row, ok := any(record).(hasSetDeletedAt); ok {
451+
if row, ok := any(record).(HasSetDeletedAt); ok {
436452
row.SetDeletedAt(time.Now().UTC())
437453
if err := t.Save(ctx, record); err != nil {
438454
return fmt.Errorf("soft delete: %w", err)
@@ -444,6 +460,27 @@ func (t *Table[T, P, I]) DeleteByID(ctx context.Context, id I) error {
444460
return t.HardDeleteByID(ctx, id)
445461
}
446462

463+
// RestoreByID restores a soft-deleted record by ID by clearing its DeletedAt timestamp.
464+
// Returns an error if the record does not implement .SetDeletedAt().
465+
func (t *Table[T, P, I]) RestoreByID(ctx context.Context, id I) error {
466+
record, err := t.GetByID(ctx, id)
467+
if err != nil {
468+
return fmt.Errorf("restore: %w", err)
469+
}
470+
471+
row, ok := any(record).(HasSetDeletedAt)
472+
if !ok {
473+
return fmt.Errorf("restore: record does not support soft delete")
474+
}
475+
476+
row.SetDeletedAt(time.Time{})
477+
if err := t.Save(ctx, record); err != nil {
478+
return fmt.Errorf("restore: %w", err)
479+
}
480+
481+
return nil
482+
}
483+
447484
// HardDeleteByID permanently deletes a record by ID.
448485
func (t *Table[T, P, I]) HardDeleteByID(ctx context.Context, id I) error {
449486
q := t.SQL.Delete(t.Name).Where(sq.Eq{t.IDColumn: id})

tests/schema_test.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,13 @@ type Article struct {
4040

4141
func (a *Article) GetID() uint64 { return a.ID }
4242
func (a *Article) SetUpdatedAt(t time.Time) { a.UpdatedAt = t }
43-
func (a *Article) SetDeletedAt(t time.Time) { a.DeletedAt = &t }
43+
func (a *Article) SetDeletedAt(t time.Time) {
44+
if t.IsZero() {
45+
a.DeletedAt = nil
46+
return
47+
}
48+
a.DeletedAt = &t
49+
}
4450

4551
func (a *Article) Validate() error {
4652
if a.Author == "" {
@@ -71,7 +77,13 @@ type Review struct {
7177

7278
func (r *Review) GetID() uint64 { return r.ID }
7379
func (r *Review) SetUpdatedAt(t time.Time) { r.UpdatedAt = t }
74-
func (r *Review) SetDeletedAt(t time.Time) { r.DeletedAt = &t }
80+
func (r *Review) SetDeletedAt(t time.Time) {
81+
if t.IsZero() {
82+
r.DeletedAt = nil
83+
return
84+
}
85+
r.DeletedAt = &t
86+
}
7587

7688
func (r *Review) Validate() error {
7789
if len(r.Comment) < 3 {

tests/table_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,19 @@ func TestTable(t *testing.T) {
182182
require.Equal(t, firstArticle.ID, article.ID, "DeletedAt should be set")
183183
require.NotNil(t, article.DeletedAt, "DeletedAt should be set")
184184

185+
// Restore first article.
186+
err = tx.Articles.RestoreByID(ctx, firstArticle.ID)
187+
require.NoError(t, err, "RestoreByID failed")
188+
189+
// Check if article is restored.
190+
article, err = tx.Articles.GetByID(ctx, firstArticle.ID)
191+
require.NoError(t, err, "GetByID failed after restore")
192+
require.Nil(t, article.DeletedAt, "DeletedAt should be nil after restore")
193+
194+
// Soft-delete again for the hard-delete test.
195+
err = tx.Articles.DeleteByID(ctx, firstArticle.ID)
196+
require.NoError(t, err, "DeleteByID failed")
197+
185198
// Hard-delete first article.
186199
err = tx.Articles.HardDeleteByID(ctx, firstArticle.ID)
187200
require.NoError(t, err, "HardDeleteByID failed")

0 commit comments

Comments
 (0)