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{} 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)) + } + } +}