Skip to content

Commit 5d4fd53

Browse files
committed
feat: add AfterScanError for partial failure in List/ListPaged
Returns all records even when some AfterScan calls fail. AfterScanError carries a map[int]error keyed by index and implements Unwrap() []error for errors.Is transitivity. Refs #43
1 parent 5ff0766 commit 5d4fd53

3 files changed

Lines changed: 60 additions & 17 deletions

File tree

table.go

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,36 @@ type AfterScanner interface {
1717
AfterScan() error
1818
}
1919

20+
// AfterScanError is returned when one or more records fail AfterScan.
21+
// The Errors map is keyed by the record's index in the returned slice.
22+
type AfterScanError struct {
23+
Errors map[int]error
24+
}
25+
26+
func (e *AfterScanError) Error() string {
27+
return fmt.Sprintf("after scan: %d error(s)", len(e.Errors))
28+
}
29+
30+
func (e *AfterScanError) Unwrap() []error {
31+
errs := make([]error, 0, len(e.Errors))
32+
for _, err := range e.Errors {
33+
errs = append(errs, err)
34+
}
35+
return errs
36+
}
37+
2038
func afterScanAll[T any](records []T) error {
39+
errs := make(map[int]error)
2140
for i := range records {
2241
if as, ok := any(records[i]).(AfterScanner); ok {
2342
if err := as.AfterScan(); err != nil {
24-
return fmt.Errorf("after scan: %w", err)
43+
errs[i] = err
2544
}
2645
}
2746
}
47+
if len(errs) > 0 {
48+
return &AfterScanError{Errors: errs}
49+
}
2850
return nil
2951
}
3052

@@ -403,11 +425,7 @@ func (t *Table[T, P, I]) List(ctx context.Context, where sq.Sqlizer, orderBy []s
403425
if err := t.Query.GetAll(ctx, q, &records); err != nil {
404426
return nil, err
405427
}
406-
if err := afterScanAll(records); err != nil {
407-
return nil, err
408-
}
409-
410-
return records, nil
428+
return records, afterScanAll(records)
411429
}
412430

413431
// ListPaged returns paginated records matching the condition.
@@ -426,10 +444,7 @@ func (t *Table[T, P, I]) ListPaged(ctx context.Context, where sq.Sqlizer, page *
426444
return nil, nil, err
427445
}
428446
result = t.Paginator.PrepareResult(result, page)
429-
if err := afterScanAll(result); err != nil {
430-
return nil, nil, err
431-
}
432-
return result, page, nil
447+
return result, page, afterScanAll(result)
433448
}
434449

435450
// Iter returns an iterator for records matching the condition.

tests/pgkit_test.go

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bufio"
55
"bytes"
66
"context"
7+
"errors"
78
"encoding/json"
89
"fmt"
910
"io"
@@ -1154,9 +1155,10 @@ func TestAfterScan(t *testing.T) {
11541155
failTable := pgkit.Table[AccountWithFailingHook, *AccountWithFailingHook, int64]{DB: DB, Name: "accounts", IDColumn: "id"}
11551156
plainTable := pgkit.Table[Account, *Account, int64]{DB: DB, Name: "accounts", IDColumn: "id"}
11561157

1157-
// Seed data.
1158+
// Seed data: "alice" and "bob" succeed AfterScan, "fail" triggers error.
11581159
require.NoError(t, hookTable.Save(ctx, &AccountWithHook{Account: Account{Name: "alice"}}))
11591160
require.NoError(t, hookTable.Save(ctx, &AccountWithHook{Account: Account{Name: "bob"}}))
1161+
require.NoError(t, hookTable.Save(ctx, &AccountWithHook{Account: Account{Name: "fail"}}))
11601162

11611163
t.Run("Get", func(t *testing.T) {
11621164
acc, err := hookTable.Get(ctx, sq.Eq{"name": "alice"}, nil)
@@ -1167,9 +1169,10 @@ func TestAfterScan(t *testing.T) {
11671169
t.Run("List", func(t *testing.T) {
11681170
accs, err := hookTable.List(ctx, nil, []string{"name"})
11691171
require.NoError(t, err)
1170-
require.Len(t, accs, 2)
1172+
require.Len(t, accs, 3)
11711173
assert.Equal(t, "ALICE", accs[0].UpperName)
11721174
assert.Equal(t, "BOB", accs[1].UpperName)
1175+
assert.Equal(t, "FAIL", accs[2].UpperName)
11731176
})
11741177

11751178
t.Run("NoHook", func(t *testing.T) {
@@ -1179,12 +1182,32 @@ func TestAfterScan(t *testing.T) {
11791182
assert.Equal(t, "alice", acc.Name)
11801183
})
11811184

1182-
t.Run("ErrorPropagation", func(t *testing.T) {
1183-
_, err := failTable.Get(ctx, sq.Eq{"name": "alice"}, nil)
1185+
t.Run("GetErrorPropagation", func(t *testing.T) {
1186+
_, err := failTable.Get(ctx, sq.Eq{"name": "fail"}, nil)
11841187
require.ErrorContains(t, err, "after scan boom")
1188+
})
11851189

1186-
_, err = failTable.List(ctx, nil, nil)
1187-
require.ErrorContains(t, err, "after scan boom")
1190+
t.Run("ListPartialFailure", func(t *testing.T) {
1191+
// "alice" and "bob" succeed, "fail" fails — all three returned.
1192+
accs, err := failTable.List(ctx, nil, []string{"name"})
1193+
require.Error(t, err)
1194+
require.Len(t, accs, 3, "all records returned despite error")
1195+
1196+
var scanErr *pgkit.AfterScanError
1197+
require.True(t, errors.As(err, &scanErr))
1198+
require.Len(t, scanErr.Errors, 1)
1199+
1200+
// "fail" sorts to index 1 (alice=0, fail=1, bob would be... let me order: alice, bob, fail → index 2)
1201+
_, ok := scanErr.Errors[2]
1202+
assert.True(t, ok, "error keyed by index of failing record")
1203+
})
1204+
1205+
t.Run("UnwrapTransitive", func(t *testing.T) {
1206+
_, err := failTable.List(ctx, nil, []string{"name"})
1207+
require.Error(t, err)
1208+
1209+
// errors.Is works transitively via Unwrap() []error.
1210+
assert.True(t, errors.Is(err, errAfterScanBoom))
11881211
})
11891212
}
11901213

tests/schema_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,17 @@ func (a *AccountWithHook) AfterScan() error {
131131

132132
func (a *AccountWithHook) DBTableName() string { return "accounts" }
133133

134+
var errAfterScanBoom = fmt.Errorf("after scan boom")
135+
134136
type AccountWithFailingHook struct {
135137
Account
136138
}
137139

138140
func (a *AccountWithFailingHook) AfterScan() error {
139-
return fmt.Errorf("after scan boom")
141+
if a.Name == "fail" {
142+
return errAfterScanBoom
143+
}
144+
return nil
140145
}
141146

142147
func (a *AccountWithFailingHook) DBTableName() string { return "accounts" }

0 commit comments

Comments
 (0)