Problem
Structs often have fields derived from DB columns but not stored — computed presentation fields, formatted IDs, signed URLs. Today the only options are:
- Hydrate manually in every handler (repetitive, easy to forget)
- Denormalize into the DB (wasteful, sync problems)
WithFS()-style methods after every read (same as 1, just wrapped)
Concrete case from OMSX (0xPolygon/omsx#252): api_keys has client_uuid UUID (DB-only) and clientId string (API-only, formatted as omsx_live_<uuid>). Five handlers, all calling HydrateClientID(mode) manually. Builder has the same pattern with AvatarKey → AvatarURL, LogoImageKey → LogoImageURL — scattered WithFS() calls.
Proposal
Optional interface, called after scanning each row:
type AfterScanner interface {
AfterScan() error
}
Usage
type apiKeyRow struct {
*proto.ApiKey
ClientUUID uuid.UUID `db:"client_uuid"`
Mode string `db:"mode"`
}
func (r *apiKeyRow) AfterScan() error {
r.ClientID = fmt.Sprintf("omsx_%s_%s", r.Mode, r.ClientUUID)
return nil
}
pgkit calls AfterScan() automatically after GetOne, GetAll, and ListPaged. No-op if the struct doesn't implement it.
Implementation
One helper function, three one-line additions:
func afterScan(dest any) error {
if as, ok := dest.(AfterScanner); ok {
return as.AfterScan()
}
v := reflect.ValueOf(dest)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() != reflect.Slice {
return nil
}
for i := 0; i < v.Len(); i++ {
elem := v.Index(i)
var as AfterScanner
var ok bool
if elem.CanAddr() {
as, ok = elem.Addr().Interface().(AfterScanner)
} else {
as, ok = elem.Interface().(AfterScanner)
}
if ok {
if err := as.AfterScan(); err != nil {
return err
}
}
}
return nil
}
Hook points in querier.go:
func (q *Querier) GetOne(ctx context.Context, query Sqlizer, dest interface{}) error {
// ... existing scan ...
if err := wrapErr(q.Scan.ScanOne(dest, rows)); err != nil {
return err
}
return afterScan(dest)
}
func (q *Querier) GetAll(ctx context.Context, query Sqlizer, dest interface{}) error {
// ... existing scan ...
if err := wrapErr(q.Scan.ScanAll(dest, rows)); err != nil {
return err
}
return afterScan(dest)
}
Same for ListPaged in table.go.
Design decisions
| Decision |
Choice |
Rationale |
| Error return |
AfterScan() error |
Cheap insurance; enables post-scan validation. Adding later would be breaking. |
| No context |
AfterScan() not AfterScan(ctx) |
Pure field derivation. No I/O. Context invites abuse. |
| Slice handling |
Reflect + CanAddr() |
Handles both []T and []*T without forcing callers into pointer slices |
| Opt-in |
Interface check |
Zero cost if unused. No registration, no global state. |
Scope
~30 lines: 1 interface, 1 helper, 3 one-liners. Smaller than the boilerplate it eliminates in a single service.
Problem
Structs often have fields derived from DB columns but not stored — computed presentation fields, formatted IDs, signed URLs. Today the only options are:
WithFS()-style methods after every read (same as 1, just wrapped)Concrete case from OMSX (0xPolygon/omsx#252):
api_keyshasclient_uuidUUID (DB-only) andclientIdstring (API-only, formatted asomsx_live_<uuid>). Five handlers, all callingHydrateClientID(mode)manually. Builder has the same pattern withAvatarKey→AvatarURL,LogoImageKey→LogoImageURL— scatteredWithFS()calls.Proposal
Optional interface, called after scanning each row:
Usage
pgkit calls
AfterScan()automatically afterGetOne,GetAll, andListPaged. No-op if the struct doesn't implement it.Implementation
One helper function, three one-line additions:
Hook points in
querier.go:Same for
ListPagedintable.go.Design decisions
AfterScan() errorAfterScan()notAfterScan(ctx)CanAddr()[]Tand[]*Twithout forcing callers into pointer slicesScope
~30 lines: 1 interface, 1 helper, 3 one-liners. Smaller than the boilerplate it eliminates in a single service.