From 64810b2178c0c36676d2024d8f0babd2cbe77d68 Mon Sep 17 00:00:00 2001 From: Vojtech Vitek Date: Fri, 31 Jul 2026 17:37:24 +0200 Subject: [PATCH 1/2] test(mapper): benchmark Map per-record cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map is called on every write (InsertRecord, UpdateRecord, and once per record in a batch InsertRecords). This adds BenchmarkMap over a representative struct so the per-record cost — tag scan, option parsing, and the column sort, all of which depend only on the struct type — is measured before the plan-cache optimization in the next commit. Pure CPU/alloc benchmark; needs no database. Co-Authored-By: Claude Opus 4.8 (1M context) --- mapper_bench_test.go | 56 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 mapper_bench_test.go diff --git a/mapper_bench_test.go b/mapper_bench_test.go new file mode 100644 index 0000000..76476d0 --- /dev/null +++ b/mapper_bench_test.go @@ -0,0 +1,56 @@ +package pgkit_test + +import ( + "testing" + "time" + + "github.com/goware/pgkit/v2" +) + +// benchMapRecord is a representative record: a mix of plain, omitempty, and +// pointer fields so Map has real per-field work (tag parsing, zero checks). +type benchMapRecord struct { + ID int64 `db:"id"` + Name string `db:"name"` + Email string `db:"email,omitempty"` + Age int32 `db:"age"` + Score float64 `db:"score,omitempty"` + Active bool `db:"active"` + Nickname *string `db:"nickname,omitempty"` + Tags []string `db:"tags,omitempty"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt *time.Time `db:"updated_at,omitempty"` + Ignored string // no db tag: must be skipped every call +} + +// BenchmarkMap measures the per-record cost of Map, which write paths +// (InsertRecord, UpdateRecord, and once per record in InsertRecords) pay on +// every call. The struct type is fixed, so the tag scan, option parsing, and +// column sort are identical across calls. +func BenchmarkMap(b *testing.B) { + nick := "nick" + now := time.Now() + rec := &benchMapRecord{ + ID: 42, + Name: "account name", + Email: "user@example.com", + Age: 30, + Score: 99.5, + Active: true, + Nickname: &nick, + Tags: []string{"a", "b", "c"}, + CreatedAt: now, + UpdatedAt: &now, + } + + b.ReportAllocs() + for b.Loop() { + cols, vals, err := pgkit.Map(rec) + if err != nil { + b.Fatal(err) + } + if len(cols) != len(vals) { + b.Fatalf("cols=%d vals=%d", len(cols), len(vals)) + } + } +} From 0bd440ae256cd15a69e4b2396fe1e6777a2ad814 Mon Sep 17 00:00:00 2001 From: Vojtech Vitek Date: Fri, 31 Jul 2026 17:39:00 +0200 Subject: [PATCH 2/2] perf(mapper): cache per-type field plan in Map Map recomputed the same per-type work on every call: scanning each field's tag, parsing ,omitempty/,omitzero options, boxing each field's zero value via reflect (fi.Zero.Interface(), one alloc per field), and sorting the columns. All of it depends only on the struct type. Compute a sorted field plan once per type and cache it (sync.Map, so reads are lock-free and no longer serialize on the reflectx mapper mutex). Each Map call now just walks the plan and reads field values. Output columns/values and their order are unchanged. BenchmarkMap (representative struct, count=8, p=0.000): time 823.4ns -> 347.4ns (-58%) B/op 654 -> 512 (-22%) allocs 12 -> 4 (-67%) Hit once per InsertRecord/UpdateRecord and once per record in a batch InsertRecords, so batch writes benefit most. Co-Authored-By: Claude Opus 4.8 (1M context) --- mapper.go | 115 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 84 insertions(+), 31 deletions(-) diff --git a/mapper.go b/mapper.go index 85a835b..d443145 100644 --- a/mapper.go +++ b/mapper.go @@ -5,6 +5,7 @@ import ( "reflect" "sort" "strings" + "sync" sq "github.com/Masterminds/squirrel" "github.com/goware/pgkit/v2/internal/reflectx" @@ -66,45 +67,35 @@ func MapWithOptions(record interface{}, options *MapOptions) ([]string, []interf recordT = recordV.Type() } - // TODO: for the same "type", we can cache the fieldinfo, etc. as it will be the same - // on subsequent loads - switch recordT.Kind() { case reflect.Struct: - fieldMap := Mapper.TypeMap(recordT).Names - nfields := len(fieldMap) - - fv.values = make([]interface{}, 0, nfields) - fv.fields = make([]string, 0, nfields) + // The db-tagged fields, their options, and the column order depend only + // on the type, so they are computed once and cached (see planForType). + plan := planForType(recordT) + if plan.err != nil { + return nil, nil, plan.err + } - for _, fi := range fieldMap { + fv.values = make([]interface{}, 0, len(plan.fields)) + fv.fields = make([]string, 0, len(plan.fields)) - // Skip any fields which do not specify the `db:".."` tag - if !strings.Contains(string(fi.Field.Tag), dbTagPrefix) { - continue - } - - // Field options - _, tagOmitEmpty := fi.Options["omitempty"] - _, tagOmitZero := fi.Options["omitzero"] - if tagOmitEmpty && tagOmitZero { - return nil, nil, fmt.Errorf("field %q has both ,omitempty and ,omitzero tags (mutually exclusive)", fi.Name) - } + for i := range plan.fields { + pf := &plan.fields[i] - fld := reflectx.FieldByIndexesReadOnly(recordV, fi.Index) + fld := reflectx.FieldByIndexesReadOnly(recordV, pf.index) if fld.Kind() == reflect.Ptr && fld.IsNil() { - if (tagOmitEmpty || tagOmitZero) && !options.IncludeNil { + if (pf.omitEmpty || pf.omitZero) && !options.IncludeNil { continue } - fv.fields = append(fv.fields, fi.Name) + fv.fields = append(fv.fields, pf.name) // ,omitempty preserves legacy: forced-include emits DEFAULT // so callers can fall back to the column's DB default. ,omitzero // is the strict tag: forced-include emits literal NULL so a // PATCH can clear a nullable column with a non-null default. var v any - if tagOmitEmpty { + if pf.omitEmpty { v = sqlDefault } fv.values = append(fv.values, v) @@ -112,17 +103,13 @@ func MapWithOptions(record interface{}, options *MapOptions) ([]string, []interf } value := fld.Interface() - isEmpty, isStrictZero := zeroFlags(fld, fi.Zero.Interface()) - skip := (isEmpty && tagOmitEmpty) || (isStrictZero && tagOmitZero) + isEmpty, isStrictZero := zeroFlags(fld, pf.zero) + skip := (isEmpty && pf.omitEmpty) || (isStrictZero && pf.omitZero) if skip && !options.IncludeZeroed { continue } - fv.fields = append(fv.fields, fi.Name) - // v, err := marshal(value) - // if err != nil { - // return nil, nil, err - // } + fv.fields = append(fv.fields, pf.name) v := value if skip { v = sqlDefault @@ -130,6 +117,10 @@ func MapWithOptions(record interface{}, options *MapOptions) ([]string, []interf fv.values = append(fv.values, v) } + // The plan is already in sorted column order, so the struct output is + // too (skips preserve order); no per-call sort needed. + return fv.fields, fv.values, nil + case reflect.Map: nfields := recordV.Len() fv.values = make([]interface{}, nfields) @@ -164,6 +155,68 @@ func MapWithOptions(record interface{}, options *MapOptions) ([]string, []interf return fv.fields, fv.values, nil } +// mapPlan is the precomputed, per-type field layout Map uses for structs: the +// db-tagged fields in final (sorted) column order. Building it once per type +// lets each Map call skip the tag scan, option parsing, and column sort. +type mapPlan struct { + fields []planField + err error // e.g. a field carrying both ,omitempty and ,omitzero +} + +type planField struct { + name string + index []int + zero any // fi.Zero.Interface(), for zeroFlags comparison + omitEmpty bool + omitZero bool +} + +// mapPlanCache maps reflect.Type -> *mapPlan. sync.Map gives lock-free reads on +// the hot path once a type has been seen. +var mapPlanCache sync.Map + +func planForType(recordT reflect.Type) *mapPlan { + if p, ok := mapPlanCache.Load(recordT); ok { + return p.(*mapPlan) + } + plan := buildPlan(recordT) + actual, _ := mapPlanCache.LoadOrStore(recordT, plan) + return actual.(*mapPlan) +} + +func buildPlan(recordT reflect.Type) *mapPlan { + fieldMap := Mapper.TypeMap(recordT).Names + plan := &mapPlan{fields: make([]planField, 0, len(fieldMap))} + + for _, fi := range fieldMap { + // Skip any fields which do not specify the `db:".."` tag. + if !strings.Contains(string(fi.Field.Tag), dbTagPrefix) { + continue + } + + _, tagOmitEmpty := fi.Options["omitempty"] + _, tagOmitZero := fi.Options["omitzero"] + if tagOmitEmpty && tagOmitZero { + return &mapPlan{err: fmt.Errorf("field %q has both ,omitempty and ,omitzero tags (mutually exclusive)", fi.Name)} + } + + plan.fields = append(plan.fields, planField{ + name: fi.Name, + index: fi.Index, + zero: fi.Zero.Interface(), + omitEmpty: tagOmitEmpty, + omitZero: tagOmitZero, + }) + } + + // Sort by column name so Map's output order matches the previous + // implementation (which sorted every call) and stays stable for cache hits. + sort.Slice(plan.fields, func(i, j int) bool { + return plan.fields[i].name < plan.fields[j].name + }) + return plan +} + type fieldValue struct { fields []string values []interface{}