feat: replace mapping generation with checked copy utilities

This commit is contained in:
2026-09-25 17:27:28 +08:00
parent ad9257d202
commit fd83235ce7
45 changed files with 2366 additions and 1872 deletions

169
copyx/options.go Normal file
View File

@@ -0,0 +1,169 @@
// Package copyx copies Go values without JSON, generated code or global registration.
package copyx
import (
"errors"
"fmt"
"reflect"
)
// NilPolicy controls nil input. NilError preserves nil for nullable targets and
// rejects nil to non-nullable targets. NilSkip retains the existing target.
type NilPolicy uint8
const (
NilError NilPolicy = iota
NilZero
NilSkip
)
type pair struct{ from, to reflect.Type }
type converter func(reflect.Value) (reflect.Value, error)
type fields struct {
names map[string]string
ignoreSource, ignoreTarget map[string]bool
}
type settings struct {
deep, skipZero, strict bool
nilPolicy NilPolicy
converters map[pair]converter
fields map[pair]*fields
}
// Option configures one immutable Copier. Options never change global behavior.
type Option func(*settings) error
func WithDeepCopy() Option { return func(s *settings) error { s.deep = true; return nil } }
// WithSkipZero explicitly skips Go zero values, including false, 0 and "".
func WithSkipZero() Option { return func(s *settings) error { s.skipZero = true; return nil } }
// WithStrictFields additionally rejects unconsumed exported source fields.
// Target fields must have a source by default, unless explicitly ignored.
func WithStrictFields() Option { return func(s *settings) error { s.strict = true; return nil } }
func WithNilPolicy(p NilPolicy) Option {
return func(s *settings) error {
if p > NilSkip {
return errors.New("copyx: invalid nil policy")
}
s.nilPolicy = p
return nil
}
}
// WithConverter registers an exact source/target type pair. Typed nils are
// delivered to converters before default nil handling. The function must not
// mutate its input and must be safe for concurrent calls when Copier is shared.
func WithConverter[S, T any](fn func(S) (T, error)) Option {
return func(s *settings) error {
if fn == nil {
return errors.New("copyx: nil converter")
}
key := pair{reflect.TypeFor[S](), reflect.TypeFor[T]()}
if _, exists := s.converters[key]; exists {
return fmt.Errorf("copyx: duplicate converter %s -> %s", key.from, key.to)
}
s.converters[key] = func(v reflect.Value) (reflect.Value, error) {
var input S
reflect.ValueOf(&input).Elem().Set(v)
out, err := fn(input)
return reflect.ValueOf(&out).Elem(), err
}
return nil
}
}
// WithFieldNames declares source -> target Go field names for one struct pair.
// It is not a JSON tag mapping. Only actual, unambiguous exported fields qualify.
func WithFieldNames[S, T any](names map[string]string) Option {
copyNames := make(map[string]string, len(names))
for k, v := range names {
copyNames[k] = v
}
return fieldOption[S, T](func(f *fields) error {
for from, to := range copyNames {
if _, exists := f.names[from]; exists {
return fmt.Errorf("copyx: duplicate field rule %s", from)
}
f.names[from] = to
}
return nil
})
}
func WithIgnoreSource[S, T any](names ...string) Option {
names = append([]string(nil), names...)
return fieldOption[S, T](func(f *fields) error {
for _, n := range names {
f.ignoreSource[n] = true
}
return nil
})
}
// WithIgnoreTarget preserves the corresponding existing target fields in Copy.
// Deep mode rejects that preservation if it would retain a source reference.
func WithIgnoreTarget[S, T any](names ...string) Option {
names = append([]string(nil), names...)
return fieldOption[S, T](func(f *fields) error {
for _, n := range names {
f.ignoreTarget[n] = true
}
return nil
})
}
func fieldOption[S, T any](apply func(*fields) error) Option {
return func(s *settings) error {
key := pair{reflect.TypeFor[S](), reflect.TypeFor[T]()}
if key.from.Kind() != reflect.Struct || key.to.Kind() != reflect.Struct {
return errors.New("copyx: field options require struct types")
}
f := s.fields[key]
if f == nil {
f = &fields{map[string]string{}, map[string]bool{}, map[string]bool{}}
s.fields[key] = f
}
return apply(f)
}
}
// Copier is immutable after construction. Each Copy owns its traversal state.
type Copier struct{ options settings }
func New(options ...Option) (*Copier, error) {
s := settings{converters: map[pair]converter{}, fields: map[pair]*fields{}}
for _, option := range options {
if option == nil {
return nil, errors.New("copyx: nil option")
}
if err := option(&s); err != nil {
return nil, err
}
}
for key, f := range s.fields {
from, to := exportedFields(key.from), exportedFields(key.to)
used := map[string]bool{}
for src, dst := range f.names {
if _, ok := from[src]; !ok {
return nil, fmt.Errorf("copyx: unknown source field %s.%s", key.from, src)
}
if _, ok := to[dst]; !ok {
return nil, fmt.Errorf("copyx: unknown target field %s.%s", key.to, dst)
}
if used[dst] || f.ignoreSource[src] || f.ignoreTarget[dst] {
return nil, fmt.Errorf("copyx: conflicting field rule %s", dst)
}
used[dst] = true
}
for n := range f.ignoreSource {
if _, ok := from[n]; !ok {
return nil, fmt.Errorf("copyx: unknown source field %s", n)
}
}
for n := range f.ignoreTarget {
if _, ok := to[n]; !ok {
return nil, fmt.Errorf("copyx: unknown target field %s", n)
}
}
}
return &Copier{options: s}, nil
}