Skip to content

Proposal: AfterScan hook interface for post-scan field hydration #43

@klaidliadon

Description

@klaidliadon

Problem

Structs often have fields derived from DB columns but not stored — computed presentation fields, formatted IDs, signed URLs. Today the only options are:

  1. Hydrate manually in every handler (repetitive, easy to forget)
  2. Denormalize into the DB (wasteful, sync problems)
  3. 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 AvatarKeyAvatarURL, LogoImageKeyLogoImageURL — 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions