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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions