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

117
copyx/benchmark_test.go Normal file
View File

@@ -0,0 +1,117 @@
package copyx_test
import (
"errors"
"fmt"
"testing"
"git.yuntuo.hk/k2safe/go-omni/copyx"
)
func BenchmarkScenarios(b *testing.B) {
type request struct {
Name string
Count int64
Active bool
}
type result struct {
Label string
Count int64
Active bool
}
source := request{"sample", 9007199254740993, true}
option := copyx.WithFieldNames[request, result](map[string]string{"Name": "Label"})
copier, err := copyx.New(option)
if err != nil {
b.Fatal(err)
}
for _, reuse := range []bool{false, true} {
b.Run(fmt.Sprintf("struct/reuse%t", reuse), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var target result
var err error
if reuse {
err = copier.Copy(&target, source)
} else {
err = copyx.Copy(&target, source, option)
}
if err != nil {
b.Fatal(err)
}
}
})
}
deep, _ := copyx.New(copyx.WithDeepCopy())
b.Run("bytes64KiB", func(b *testing.B) {
src := make([]byte, 64<<10)
b.SetBytes(int64(len(src)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var dst []byte
if err := deep.Copy(&dst, src); err != nil {
b.Fatal(err)
}
}
})
src := map[string][]int{}
for i := 0; i < 100; i++ {
src[fmt.Sprint(i)] = []int{1, 2, 3}
}
b.Run("map100", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var dst map[string][]int
if err := deep.Copy(&dst, src); err != nil {
b.Fatal(err)
}
}
})
cycle := &detail{Values: []int{1, 2, 3}}
cycle.Next = cycle
b.Run("cycle", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var dst *detail
if err := deep.Copy(&dst, cycle); err != nil {
b.Fatal(err)
}
}
})
b.Run("parallelCycle", func(b *testing.B) {
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
var dst *detail
if err := deep.Copy(&dst, cycle); err != nil {
b.Error(err)
return
}
}
})
})
for _, fresh := range []bool{false, true} {
b.Run(fmt.Sprintf("converter/fresh%t", fresh), func(b *testing.B) {
c, err := copyx.New(copyx.WithDeepCopy(), copyx.WithConverter(func(v []int) ([]int, error) {
if fresh {
return append([]int(nil), v...), nil
}
return v, nil
}))
if err != nil {
b.Fatal(err)
}
src := []int{1, 2, 3}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var dst []int
err := c.Copy(&dst, src)
if fresh && err != nil || !fresh && !errors.Is(err, copyx.ErrSharedReference) {
b.Fatal(err)
}
}
})
}
}

537
copyx/copy.go Normal file
View File

@@ -0,0 +1,537 @@
package copyx
import (
"errors"
"fmt"
"reflect"
"runtime"
"sort"
"sync"
)
var (
ErrType = errors.New("copyx: incompatible types")
ErrNil = errors.New("copyx: nil cannot fill a non-nullable target")
ErrField = errors.New("copyx: unmatched field")
ErrUnsupported = errors.New("copyx: unsupported value; provide an explicit data converter")
ErrSharedReference = errors.New("copyx: deep copy retains a mutable source reference")
ErrLimit = errors.New("copyx: traversal limit exceeded")
)
// Error describes a field/index path without recording the source value.
type Error struct {
Path string
Cause error
}
func (e *Error) Error() string { return e.Path + ": " + e.Cause.Error() }
func (e *Error) Unwrap() error { return e.Cause }
func at(path string, err error) error { return &Error{Path: path, Cause: err} }
func Copy(target, source any, options ...Option) error {
c, err := New(options...)
if err != nil {
return err
}
return c.Copy(target, source)
}
// Clone always makes a deep copy. Nil and empty collections stay distinct.
func Clone[T any](source T, options ...Option) (T, error) {
var target T
options = append(append([]Option(nil), options...), WithDeepCopy())
err := Copy(&target, source, options...)
return target, err
}
// Copy stages the complete result and only assigns target on success. User
// converter side effects (including mutation of source) cannot be rolled back.
// Caller must synchronize concurrent access to source and target themselves.
func (c *Copier) Copy(target, source any) error {
// Alias indexes contain addresses only. Keep the original graph alive until
// validation and commit finish so the GC cannot recycle any indexed address.
defer func() { runtime.KeepAlive(source) }()
if c == nil {
return errors.New("copyx: nil Copier")
}
dst := reflect.ValueOf(target)
if !dst.IsValid() || dst.Kind() != reflect.Pointer || dst.IsNil() {
return errors.New("copyx: target must be a non-nil pointer")
}
src := reflect.ValueOf(source)
s := state{options: &c.options, seen: map[visit]reflect.Value{}}
if c.options.deep {
var err error
s.graph, err = sourceGraph(src)
if err != nil {
return err
}
if src.IsValid() && src.Kind() == reflect.Pointer && !src.IsNil() && dst.Elem().Kind() == reflect.Struct {
id := identity(src)
s.valueRoot = &id
}
}
out, err := s.walk(src, dst.Elem(), dst.Elem().Type(), "$", 0)
if err != nil {
return err
}
if s.graph != nil {
if err = s.graph.audit(out); err != nil {
return err
}
}
dst.Elem().Set(out)
return nil
}
type visit struct {
reference
target reflect.Type
}
type state struct {
options *settings
seen map[visit]reflect.Value
graph *graph
nodes int
allocated uint64
valueRoot *reference
}
func (s *state) reserve(count int, size uintptr, path string) error {
if count < 0 || (count > 0 && uint64(size) > maxCopyBytes/uint64(count)) {
return at(path, ErrLimit)
}
s.allocated += uint64(count) * uint64(size)
if s.allocated > maxCopyBytes {
return at(path, ErrLimit)
}
return nil
}
func nullable(k reflect.Kind) bool {
switch k {
case reflect.Pointer, reflect.Map, reflect.Slice, reflect.Interface, reflect.Func, reflect.Chan:
return true
}
return false
}
func baseValue(base reflect.Value, t reflect.Type) reflect.Value {
if base.IsValid() && base.Type() == t {
return base
}
return reflect.Zero(t)
}
func (s *state) walk(src, base reflect.Value, t reflect.Type, path string, depth int) (reflect.Value, error) {
s.nodes++
if depth > maxDepth || s.nodes > maxNodes {
return reflect.Value{}, at(path, ErrLimit)
}
base = baseValue(base, t)
if !shallowSafe(t) {
return reflect.Value{}, at(path, ErrUnsupported)
}
if src.IsValid() && !src.CanInterface() {
return reflect.Value{}, at(path, ErrUnsupported)
}
// Exact converters also receive typed nils and zeros. No method calls,
// Scanner/Valuer invocation or implicit numeric conversion is attempted.
if src.IsValid() {
if convert, ok := s.options.converters[pair{src.Type(), t}]; ok {
out, err := convert(src)
if err != nil {
return reflect.Value{}, at(path, err)
}
if s.graph != nil {
if err = s.graph.audit(out); err != nil {
return reflect.Value{}, at(path, err)
}
}
return out, nil
}
}
if !src.IsValid() || (nullable(src.Kind()) && src.IsNil()) {
if src.IsValid() && !s.nilCompatible(src.Type(), t, map[pair]bool{}) {
return reflect.Value{}, at(path, ErrType)
}
if s.options.nilPolicy == NilSkip {
return base, nil
}
if nullable(t.Kind()) || s.options.nilPolicy == NilZero {
return reflect.Zero(t), nil
}
return reflect.Value{}, at(path, ErrNil)
}
if s.options.skipZero && src.IsZero() {
if !s.nilCompatible(src.Type(), t, map[pair]bool{}) {
return reflect.Value{}, at(path, ErrType)
}
return base, nil
}
if src.Type() == t && len(s.options.converters) == 0 && len(s.options.fields) == 0 && !s.options.skipZero && s.options.nilPolicy != NilSkip {
if !s.options.deep || simpleValue(t) {
return src, nil
}
}
if src.Kind() == reflect.Interface {
return s.walk(src.Elem(), base, t, path, depth+1)
}
if t.Kind() == reflect.Interface {
if !src.Type().Implements(t) {
return reflect.Value{}, at(path, ErrType)
}
out, err := s.walk(src, reflect.Value{}, src.Type(), path, depth+1)
if err != nil {
return reflect.Value{}, err
}
v := reflect.New(t).Elem()
v.Set(out)
return v, nil
}
if s.options.deep && forbidden(t) {
return reflect.Value{}, at(path, ErrUnsupported)
}
if src.Kind() == reflect.Pointer && t.Kind() != reflect.Pointer {
return s.walk(src.Elem(), base, t, path, depth+1)
}
if t.Kind() == reflect.Pointer {
if !s.options.deep && src.Type() == t {
return src, nil
}
if src.Kind() == reflect.Pointer {
key := visit{identity(src), t}
if s.valueRoot != nil && key.reference == *s.valueRoot {
return reflect.Value{}, at(path, fmt.Errorf("%w: cyclic root must retain its pointer; use Clone(source) or Copy(&targetPointer, source)", ErrUnsupported))
}
if prior, ok := s.seen[key]; ok {
return prior, nil
}
}
if err := s.reserve(1, t.Elem().Size(), path); err != nil {
return reflect.Value{}, err
}
out := reflect.New(t.Elem())
if out.Type() != t {
out = out.Convert(t)
}
if src.Kind() == reflect.Pointer {
key := visit{identity(src), t}
s.seen[key] = out
src = src.Elem()
}
var childBase reflect.Value
if !base.IsNil() {
childBase = base.Elem()
}
value, err := s.walk(src, childBase, t.Elem(), path, depth+1)
if err != nil {
return reflect.Value{}, err
}
out.Elem().Set(value)
return out, nil
}
if src.Kind() != t.Kind() {
return reflect.Value{}, at(path, fmt.Errorf("%w: %s -> %s", ErrType, src.Type(), t))
}
if t == timeType && src.Type() == t {
return src, nil
}
switch t.Kind() {
case reflect.Struct:
return s.structure(src, base, t, path, depth)
case reflect.Slice:
if !s.options.deep && src.Type() == t {
return src, nil
}
key := visit{identity(src), t}
if prior, ok := s.seen[key]; ok {
return prior, nil
}
if err := s.reserve(src.Len(), t.Elem().Size(), path); err != nil {
return reflect.Value{}, err
}
out := reflect.MakeSlice(t, src.Len(), src.Len())
s.seen[key] = out
if src.Type() == t && len(s.options.converters) == 0 && len(s.options.fields) == 0 && simpleValue(t.Elem()) {
reflect.Copy(out, src)
return out, nil
}
for i := 0; i < src.Len(); i++ {
v, err := s.walk(src.Index(i), reflect.Value{}, t.Elem(), fmt.Sprintf("%s[%d]", path, i), depth+1)
if err != nil {
return reflect.Value{}, err
}
out.Index(i).Set(v)
}
return out, nil
case reflect.Array:
if src.Len() != t.Len() {
return reflect.Value{}, at(path, ErrType)
}
if err := s.reserve(1, t.Size(), path); err != nil {
return reflect.Value{}, err
}
out := reflect.New(t).Elem()
for i := 0; i < src.Len(); i++ {
v, err := s.walk(src.Index(i), base.Index(i), t.Elem(), fmt.Sprintf("%s[%d]", path, i), depth+1)
if err != nil {
return reflect.Value{}, err
}
out.Index(i).Set(v)
}
return out, nil
case reflect.Map:
if !s.options.deep && src.Type() == t {
return src, nil
}
key := visit{identity(src), t}
if prior, ok := s.seen[key]; ok {
return prior, nil
}
if err := s.reserve(src.Len(), t.Key().Size()+t.Elem().Size()+32, path); err != nil {
return reflect.Value{}, err
}
out := reflect.MakeMapWithSize(t, src.Len())
s.seen[key] = out
it := src.MapRange()
for it.Next() {
k, err := s.walk(it.Key(), reflect.Value{}, t.Key(), path+"[key]", depth+1)
if err != nil {
return reflect.Value{}, err
}
if !k.Comparable() {
return reflect.Value{}, at(path+"[key]", ErrType)
}
if out.MapIndex(k).IsValid() {
return reflect.Value{}, at(path+"[key]", errors.New("copyx: key conversion collision"))
}
v, err := s.walk(it.Value(), reflect.Value{}, t.Elem(), path+"[value]", depth+1)
if err != nil {
return reflect.Value{}, err
}
out.SetMapIndex(k, v)
}
return out, nil
case reflect.Func, reflect.Chan, reflect.UnsafePointer:
if s.options.deep {
return reflect.Value{}, at(path, ErrUnsupported)
}
}
if src.Type() != t {
return reflect.Value{}, at(path, fmt.Errorf("%w: %s -> %s", ErrType, src.Type(), t))
}
return src, nil
}
var fieldTypes sync.Map
func exportedFields(t reflect.Type) map[string]reflect.StructField {
if known, ok := fieldTypes.Load(t); ok {
return known.(map[string]reflect.StructField)
}
out := map[string]reflect.StructField{}
for _, f := range reflect.VisibleFields(t) {
if f.PkgPath != "" {
continue
}
kind := f.Type.Kind()
if kind == reflect.Pointer {
kind = f.Type.Elem().Kind()
}
if f.Anonymous && kind == reflect.Struct {
continue
}
out[f.Name] = f
}
actual, _ := fieldTypes.LoadOrStore(t, out)
return actual.(map[string]reflect.StructField)
}
func (s *state) nilCompatible(from, to reflect.Type, seen map[pair]bool) bool {
key := pair{from, to}
if seen[key] {
return true
}
seen[key] = true
if from == to {
return true
}
if _, ok := s.options.converters[key]; ok {
return true
}
if to.Kind() == reflect.Interface {
return from.Implements(to)
}
if from.Kind() == reflect.Pointer {
return s.nilCompatible(from.Elem(), to, seen)
}
if to.Kind() == reflect.Pointer {
return s.nilCompatible(from, to.Elem(), seen)
}
if from.Kind() != to.Kind() {
return false
}
switch from.Kind() {
case reflect.Struct:
// Nil must not hide a structurally incompatible type pair. There are no
// field values to visit, but coverage and conversion types still apply.
for i := 0; i < to.NumField(); i++ {
if to.Field(i).PkgPath != "" {
return false
}
}
sources, targets := exportedFields(from), exportedFields(to)
rules := s.options.fields[key]
if rules == nil {
rules = &fields{}
}
reverse, used := map[string]string{}, map[string]bool{}
for a, b := range rules.names {
reverse[b] = a
}
for name, target := range targets {
if rules.ignoreTarget[name] {
continue
}
srcName := name
if renamed, ok := reverse[name]; ok {
srcName = renamed
}
source, ok := sources[srcName]
if !ok || rules.ignoreSource[srcName] || !s.nilCompatible(source.Type, target.Type, seen) {
return false
}
used[srcName] = true
}
if s.options.strict {
for name := range sources {
if !used[name] && !rules.ignoreSource[name] {
return false
}
}
}
return true
case reflect.Slice:
return s.nilCompatible(from.Elem(), to.Elem(), seen)
case reflect.Map:
return s.nilCompatible(from.Key(), to.Key(), seen) && s.nilCompatible(from.Elem(), to.Elem(), seen)
case reflect.Array:
return from.Len() == to.Len() && s.nilCompatible(from.Elem(), to.Elem(), seen)
}
return false
}
func fieldValue(v reflect.Value, index []int) (reflect.Value, bool) {
for _, i := range index {
if v.Kind() == reflect.Pointer {
if v.IsNil() {
return reflect.Value{}, false
}
v = v.Elem()
}
v = v.Field(i)
}
return v, true
}
func setField(v reflect.Value, index []int, value reflect.Value) error {
for j, i := range index {
field := v.Field(i)
if !field.CanSet() {
return ErrUnsupported
}
if j == len(index)-1 {
field.Set(value)
return nil
}
if field.Kind() == reflect.Pointer {
// Detach embedded destination pointers before editing promoted fields.
fresh := reflect.New(field.Type().Elem())
if !field.IsNil() {
fresh.Elem().Set(field.Elem())
}
field.Set(fresh)
v = fresh.Elem()
} else {
v = field
}
}
return nil
}
func (s *state) structure(src, base reflect.Value, t reflect.Type, path string, depth int) (reflect.Value, error) {
if err := s.reserve(1, t.Size(), path); err != nil {
return reflect.Value{}, err
}
out := reflect.New(t).Elem()
out.Set(base)
if src.Type() == t {
// Private scalar state may be assigned as part of its whole value; mutable
// private state requires a converter, never unsafe writes or silent loss.
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if f.PkgPath != "" && s.options.deep && privateMutable(f.Type) {
return reflect.Value{}, at(path+"."+f.Name, ErrUnsupported)
}
}
out.Set(src)
} else {
for i := 0; i < t.NumField(); i++ {
if t.Field(i).PkgPath != "" {
return reflect.Value{}, at(path, ErrUnsupported)
}
}
}
from, to := exportedFields(src.Type()), exportedFields(t)
rules := s.options.fields[pair{src.Type(), t}]
if rules == nil {
rules = &fields{map[string]string{}, map[string]bool{}, map[string]bool{}}
}
reverse := map[string]string{}
for a, b := range rules.names {
reverse[b] = a
}
names := make([]string, 0, len(to))
for n := range to {
names = append(names, n)
}
sort.Strings(names)
used := map[string]bool{}
for _, name := range names {
target := to[name]
old, _ := fieldValue(base, target.Index)
if rules.ignoreTarget[name] {
if !old.IsValid() {
old = reflect.Zero(target.Type)
}
if err := setField(out, target.Index, old); err != nil {
return reflect.Value{}, at(path+"."+name, err)
}
continue
}
srcName := name
if renamed, ok := reverse[name]; ok {
srcName = renamed
}
source, ok := from[srcName]
if !ok || rules.ignoreSource[srcName] {
return reflect.Value{}, at(path+"."+name, ErrField)
}
used[srcName] = true
v, ok := fieldValue(src, source.Index)
if !ok {
return reflect.Value{}, at(path+"."+name, ErrNil)
}
value, err := s.walk(v, old, target.Type, path+"."+name, depth+1)
if err != nil {
return reflect.Value{}, err
}
if err = setField(out, target.Index, value); err != nil {
return reflect.Value{}, at(path+"."+name, err)
}
}
if s.options.strict {
for name := range from {
if !used[name] && !rules.ignoreSource[name] {
return reflect.Value{}, at(path+"."+name, ErrField)
}
}
}
return out, nil
}

448
copyx/copy_test.go Normal file
View File

@@ -0,0 +1,448 @@
package copyx_test
import (
"errors"
"fmt"
"reflect"
"strings"
"sync"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/copyx"
)
type detail struct {
Values []int
Index map[string][]int
Next *detail
}
func TestDeepGraph(t *testing.T) {
src := &detail{Values: []int{1, 2}, Index: map[string][]int{"a": {3}}}
src.Next = src
dst, err := copyx.Clone(src)
if err != nil {
t.Fatal(err)
}
if dst == src || dst.Next != dst {
t.Fatal("pointer cycle not preserved")
}
dst.Values[0] = 9
dst.Index["a"][0] = 8
if src.Values[0] != 1 || src.Index["a"][0] != 3 {
t.Fatal("deep alias")
}
m := map[string]any{}
m["self"] = m
n, err := copyx.Clone(m)
if err != nil {
t.Fatal(err)
}
n["self"].(map[string]any)["new"] = 1
if n["new"] != 1 || m["new"] != nil {
t.Fatal("map cycle changed")
}
x := []any{nil}
x[0] = x
y, err := copyx.Clone(x)
if err != nil {
t.Fatal(err)
}
y[0].([]any)[0] = "changed"
if y[0] != "changed" {
t.Fatal("slice cycle changed")
}
if _, ok := x[0].([]any); !ok {
t.Fatal("source slice changed")
}
}
func TestShallowAndNil(t *testing.T) {
src := detail{Values: []int{1}}
var dst detail
if err := copyx.Copy(&dst, src); err != nil {
t.Fatal(err)
}
dst.Values[0] = 2
if src.Values[0] != 2 {
t.Fatal("shallow unexpectedly detached")
}
for _, slice := range [][]int{nil, {}} {
out, err := copyx.Clone(slice)
if err != nil || (out == nil) != (slice == nil) {
t.Fatal(out, err)
}
}
for _, m := range []map[string]int{nil, {}} {
out, err := copyx.Clone(m)
if err != nil || (out == nil) != (m == nil) {
t.Fatal(out, err)
}
}
var p *int
n := 7
if err := copyx.Copy(&n, p); !errors.Is(err, copyx.ErrNil) || n != 7 {
t.Fatal(n, err)
}
if err := copyx.Copy(&n, p, copyx.WithNilPolicy(copyx.NilSkip)); err != nil || n != 7 {
t.Fatal(n, err)
}
if err := copyx.Copy(&n, p, copyx.WithNilPolicy(copyx.NilZero)); err != nil || n != 0 {
t.Fatal(n, err)
}
x := 3
out := &x
if err := copyx.Copy(&out, p); err != nil || out != nil {
t.Fatal(out, err)
}
flag := true
if err := copyx.Copy(&flag, false); err != nil || flag {
t.Fatal(flag, err)
}
flag = true
if err := copyx.Copy(&flag, false, copyx.WithSkipZero()); err != nil || !flag {
t.Fatal(flag, err)
}
}
func TestConverterAliasesRejected(t *testing.T) {
for _, kind := range []string{"same", "nested", "subslice", "capacity", "fieldPointer", "private", "map", "sibling"} {
t.Run(kind, func(t *testing.T) {
src := &detail{Values: []int{1, 2, 3}, Index: map[string][]int{"x": {1}}}
var err error
switch kind {
case "same":
_, err = copyx.Clone(src, copyx.WithConverter(func(v *detail) (*detail, error) { return v, nil }))
case "nested":
_, err = copyx.Clone(src, copyx.WithConverter(func(v *detail) (*detail, error) { return &detail{Values: v.Values}, nil }))
case "subslice":
_, err = copyx.Clone(src.Values, copyx.WithConverter(func(v []int) ([]int, error) { return v[1:], nil }))
case "capacity":
_, err = copyx.Clone(src.Values, copyx.WithConverter(func(v []int) ([]int, error) { return v[:0], nil }))
case "map":
_, err = copyx.Clone(src.Index, copyx.WithConverter(func(v map[string][]int) (map[string][]int, error) { return v, nil }))
case "fieldPointer":
var dst *int
err = copyx.Copy(&dst, src.Values, copyx.WithDeepCopy(), copyx.WithConverter(func(v []int) (*int, error) { return &v[1], nil }))
case "private":
type opaque struct{ data []int }
v := opaque{src.Values}
_, err = copyx.Clone(v, copyx.WithConverter(func(v opaque) (opaque, error) { return v, nil }))
case "sibling":
type object struct{ A, B []int }
v := object{[]int{1}, []int{2}}
_, err = copyx.Clone(v, copyx.WithConverter(func(_ []int) ([]int, error) { return v.B, nil }))
}
if !errors.Is(err, copyx.ErrSharedReference) {
t.Fatalf("got %v", err)
}
})
}
_, err := copyx.Clone(4, copyx.WithConverter(func(v int) (int, error) { return v, nil }))
if err != nil {
t.Fatal("equal primitive rejected", err)
}
_, err = copyx.Clone("same", copyx.WithConverter(func(v string) (string, error) { return v, nil }))
if err != nil {
t.Fatal(err)
}
good, err := copyx.Clone([]int{1}, copyx.WithConverter(func(v []int) ([]int, error) { return append([]int(nil), v...), nil }))
if err != nil || good[0] != 1 {
t.Fatal(good, err)
}
}
func TestPrivateAndUnsupported(t *testing.T) {
type money struct{ cents *int64 }
n := int64(9007199254740991)
src := money{&n}
if _, err := copyx.Clone(src); !errors.Is(err, copyx.ErrUnsupported) {
t.Fatal(err)
}
out, err := copyx.Clone(src, copyx.WithConverter(func(v money) (money, error) { n := *v.cents; return money{&n}, nil }))
if err != nil || *out.cents != n || out.cents == src.cents {
t.Fatal(out, err)
}
tm := time.Now()
cloned, err := copyx.Clone(tm)
if err != nil || !cloned.Equal(tm) {
t.Fatal(cloned, err)
}
for _, v := range []any{make(chan int), func() {}, &sync.Mutex{}, sync.Mutex{}} {
if _, err := copyx.Clone(v); !errors.Is(err, copyx.ErrUnsupported) {
t.Fatalf("%T: %v", v, err)
}
}
}
func TestNilTypeMismatchAndInterfaceConverter(t *testing.T) {
var source *string
target := 7
if err := copyx.Copy(&target, source, copyx.WithNilPolicy(copyx.NilZero)); !errors.Is(err, copyx.ErrType) || target != 7 {
t.Fatal(target, err)
}
var list []int
var targetMap map[string]int
if err := copyx.Copy(&targetMap, list); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
type input struct{ Value any }
type output struct{ Value string }
var out output
err := copyx.Copy(&out, input{}, copyx.WithConverter(func(v any) (string, error) {
if v == nil {
return "nil handled", nil
}
return "value", nil
}))
if err != nil || out.Value != "nil handled" {
t.Fatal(out, err)
}
var lock sync.Mutex
if err := copyx.Copy(&lock, sync.Mutex{}); !errors.Is(err, copyx.ErrUnsupported) {
t.Fatal(err)
}
type locked struct {
mu sync.Mutex
Value int
}
var l locked
if err := copyx.Copy(&l, locked{}); !errors.Is(err, copyx.ErrUnsupported) {
t.Fatal(err)
}
}
func TestAllocationLimitAndPointerRoot(t *testing.T) {
src := make([]byte, (64<<20)+1)
if _, err := copyx.Clone(src); !errors.Is(err, copyx.ErrLimit) {
t.Fatal(err)
}
n := &detail{}
n.Next = n
var value detail
if err := copyx.Copy(&value, n, copyx.WithDeepCopy()); !errors.Is(err, copyx.ErrUnsupported) {
t.Fatal(err)
}
var p *detail
if err := copyx.Copy(&p, n, copyx.WithDeepCopy()); err != nil || p.Next != p || p == n {
t.Fatal(p, err)
}
}
func TestFieldsConvertersAndAtomicity(t *testing.T) {
type input struct {
Name string
Age int
Extra string
}
type output struct {
Label string
Age int
Local string
}
rename := map[string]string{"Name": "Label"}
c, err := copyx.New(copyx.WithFieldNames[input, output](rename), copyx.WithIgnoreTarget[input, output]("Local"))
if err != nil {
t.Fatal(err)
}
rename["Name"] = "Missing"
dst := output{Local: "preserved"}
if err = c.Copy(&dst, input{Name: "hello", Age: 2}); err != nil || dst.Label != "hello" || dst.Local != "preserved" {
t.Fatal(dst, err)
}
if err = copyx.Copy(&dst, input{}, copyx.WithStrictFields(), copyx.WithFieldNames[input, output](map[string]string{"Name": "Label"}), copyx.WithIgnoreTarget[input, output]("Local")); !errors.Is(err, copyx.ErrField) {
t.Fatal(err)
}
if err = copyx.Copy(&dst, input{}, copyx.WithStrictFields(), copyx.WithFieldNames[input, output](map[string]string{"Name": "Label"}), copyx.WithIgnoreTarget[input, output]("Local"), copyx.WithIgnoreSource[input, output]("Extra")); err != nil {
t.Fatal(err)
}
if err = copyx.Copy(&dst, input{}); !errors.Is(err, copyx.ErrField) {
t.Fatal(err)
}
if _, err = copyx.New(copyx.WithFieldNames[input, output](map[string]string{"Nope": "Age"})); err == nil {
t.Fatal("invalid rule accepted")
}
if _, err = copyx.New(copyx.WithConverter(func(v int) (string, error) { return "", nil }), copyx.WithConverter(func(v int) (string, error) { return "", nil })); err == nil {
t.Fatal("duplicate converter accepted")
}
sentinel := errors.New("conversion failed")
type node struct {
A []int
Z string
}
old := node{[]int{8}, "old"}
before := old.A
err = copyx.Copy(&old, node{[]int{1}, "bad"}, copyx.WithDeepCopy(), copyx.WithConverter(func(v string) (string, error) { return "", sentinel }))
if !errors.Is(err, sentinel) || !strings.Contains(err.Error(), "Z") || old.Z != "old" || old.A[0] != 8 || &old.A[0] != &before[0] {
t.Fatal(old, err)
}
// Exact typed nil converter must get control before the global nil policy.
var ptr *int
var value int
err = copyx.Copy(&value, ptr, copyx.WithConverter(func(v *int) (int, error) {
if v == nil {
return 0, sentinel
}
return *v, nil
}))
if !errors.Is(err, sentinel) {
t.Fatal(err)
}
var small int8 = 3
if err = copyx.Copy(&small, int64(300)); !errors.Is(err, copyx.ErrType) || small != 3 {
t.Fatal(small, err)
}
}
type Embedded struct {
A int
Z string
}
func TestEmbeddedDestinationDetached(t *testing.T) {
type target struct{ *Embedded }
type source struct {
A int
Z int
}
ptr := &Embedded{5, "keep"}
dst := target{ptr}
if err := copyx.Copy(&dst, source{6, 7}); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
if ptr.A != 5 || dst.Embedded != ptr {
t.Fatal("failure mutated embedded target")
}
if err := copyx.Copy(&dst, Embedded{6, "new"}); err != nil {
t.Fatal(err)
}
if ptr.A != 5 || dst.A != 6 {
t.Fatal("success mutated old embedded object")
}
}
func TestConcurrentCopier(t *testing.T) {
c, err := copyx.New(copyx.WithDeepCopy())
if err != nil {
t.Fatal(err)
}
src := &detail{Values: []int{7}, Index: map[string][]int{"a": {9}}}
var wg sync.WaitGroup
for i := 0; i < 64; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for n := 0; n < 20; n++ {
var dst *detail
if err := c.Copy(&dst, src); err != nil {
t.Error(err)
return
}
dst.Values[0] = n
if src.Values[0] != 7 {
t.Error("shared")
}
}
}()
}
wg.Wait()
}
func TestBoundAndCollections(t *testing.T) {
src := &detail{}
p := src
for i := 0; i < 300; i++ {
p.Next = &detail{}
p = p.Next
}
if _, err := copyx.Clone(src); !errors.Is(err, copyx.ErrLimit) {
t.Fatal(err)
}
type from struct{ Value string }
type to struct{ Value string }
var out []to
if err := copyx.Copy(&out, []from{{"a"}, {"b"}}); err != nil || len(out) != 2 || out[1].Value != "b" {
t.Fatal(out, err)
}
var array [2]to
if err := copyx.Copy(&array, [2]from{{"a"}, {"b"}}); err != nil {
t.Fatal(err)
}
if err := copyx.Copy(&array, [1]from{{"a"}}); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
var outMap map[string]int
err := copyx.Copy(&outMap, map[int]int{1: 1, 2: 2}, copyx.WithConverter(func(int) (string, error) { return "same", nil }))
if err == nil || outMap != nil {
t.Fatal(outMap, err)
}
}
func FuzzClone(f *testing.F) {
f.Add([]byte{1, 2, 3})
f.Add([]byte{})
f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > 1024 {
data = data[:1024]
}
src := map[string]any{"bytes": data, "other": []string{"x"}}
out, err := copyx.Clone(src)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(src, out) {
t.Fatal("changed value")
}
if len(data) > 0 {
out["bytes"].([]byte)[0] ^= 255
if data[0] == out["bytes"].([]byte)[0] {
t.Fatal("alias")
}
}
})
}
func TestTraversalNodeLimit(t *testing.T) {
source := make([]any, 200001)
if _, err := copyx.Clone(source); !errors.Is(err, copyx.ErrLimit) {
t.Fatal("expected bounded traversal", err)
}
}
func TestNilCannotHideFieldMismatch(t *testing.T) {
type source struct{ Value string }
type target struct{ Value int }
var src *source
var dst *target
for _, policy := range []copyx.NilPolicy{copyx.NilError, copyx.NilZero, copyx.NilSkip} {
if err := copyx.Copy(&dst, src, copyx.WithNilPolicy(policy)); !errors.Is(err, copyx.ErrType) {
t.Fatal("nil concealed incompatible fields", err)
}
}
if err := copyx.Copy(&dst, src, copyx.WithConverter(func(string) (int, error) { return 0, nil })); err != nil {
t.Fatal(err)
}
text := "keep"
if err := copyx.Copy(&text, 0, copyx.WithSkipZero()); !errors.Is(err, copyx.ErrType) || text != "keep" {
t.Fatal("zero concealed incompatible types", text, err)
}
}
func BenchmarkCopy(b *testing.B) {
for _, n := range []int{1, 100, 1000} {
for _, deep := range []bool{false, true} {
b.Run(fmt.Sprintf("fields%d/deep%t", n, deep), func(b *testing.B) {
src := detail{Values: make([]int, n), Index: map[string][]int{"one": {1, 2}}}
var opts []copyx.Option
if deep {
opts = append(opts, copyx.WithDeepCopy())
}
c, _ := copyx.New(opts...)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var dst detail
if err := c.Copy(&dst, src); err != nil {
b.Fatal(err)
}
}
})
}
}
}

270
copyx/graph.go Normal file
View File

@@ -0,0 +1,270 @@
package copyx
import (
"fmt"
"reflect"
"sort"
"sync"
"time"
)
const maxDepth = 256
const maxNodes = 200000
const maxCopyBytes = 64 << 20
var timeType = reflect.TypeFor[time.Time]()
// Immutable type metadata is shared, never values or converter registrations.
var valueTypes sync.Map
func simpleValue(t reflect.Type) bool {
if known, ok := valueTypes.Load(t); ok {
return known.(bool)
}
result := t == timeType
if !result {
switch t.Kind() {
case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr,
reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128, reflect.String:
result = true
case reflect.Array:
result = simpleValue(t.Elem())
case reflect.Struct:
result = !forbidden(t)
for i := 0; result && i < t.NumField(); i++ {
result = simpleValue(t.Field(i).Type)
}
}
}
valueTypes.Store(t, result)
return result
}
type reference struct {
kind reflect.Kind
typ reflect.Type
ptr uintptr
length, capacity int
}
func identity(v reflect.Value) reference {
r := reference{kind: v.Kind(), typ: v.Type(), ptr: uintptr(v.UnsafePointer())}
if v.Kind() == reflect.Slice {
r.length = v.Len()
r.capacity = v.Cap()
}
return r
}
type span struct{ start, end uintptr }
type graph struct {
maps map[uintptr]bool
spans []span
maxEnd []uintptr
}
// References are inspected without reading private values through Interface or
// using unsafe writes. Slice capacity matters: returning source[:0] is an alias.
func scanGraph(v reflect.Value, visit func(reflect.Value, string) error) error {
seen := map[reference]bool{}
nodes := 0
var walk func(reflect.Value, string, int) error
walk = func(v reflect.Value, path string, depth int) error {
nodes++
if depth > maxDepth || nodes > maxNodes {
return at(path, ErrLimit)
}
if !v.IsValid() {
return nil
}
if err := visit(v, path); err != nil {
return err
}
if simpleValue(v.Type()) {
return nil
} // time.Time is a documented immutable value.
switch v.Kind() {
case reflect.Interface:
if !v.IsNil() {
return walk(v.Elem(), path, depth+1)
}
case reflect.Pointer, reflect.Map, reflect.Slice:
if v.IsNil() {
return nil
}
id := identity(v)
if seen[id] {
return nil
}
seen[id] = true
switch v.Kind() {
case reflect.Pointer:
return walk(v.Elem(), path, depth+1)
case reflect.Map:
it := v.MapRange()
for it.Next() {
if err := walk(it.Key(), path+"[key]", depth+1); err != nil {
return err
}
if err := walk(it.Value(), path+"[value]", depth+1); err != nil {
return err
}
}
case reflect.Slice:
if simpleValue(v.Type().Elem()) {
return nil
}
all := v.Slice(0, v.Cap())
for i := 0; i < all.Len(); i++ {
if err := walk(all.Index(i), fmt.Sprintf("%s[%d]", path, i), depth+1); err != nil {
return err
}
}
}
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
if err := walk(v.Field(i), path+"."+v.Type().Field(i).Name, depth+1); err != nil {
return err
}
}
case reflect.Array:
for i := 0; i < v.Len(); i++ {
if err := walk(v.Index(i), fmt.Sprintf("%s[%d]", path, i), depth+1); err != nil {
return err
}
}
}
return nil
}
return walk(v, "$", 0)
}
func mutableSpan(v reflect.Value) (span, bool) {
var n uintptr
switch v.Kind() {
case reflect.Pointer:
if v.IsNil() {
return span{}, false
}
n = v.Type().Elem().Size()
case reflect.Slice:
if v.IsNil() {
return span{}, false
}
n = uintptr(v.Cap()) * v.Type().Elem().Size()
default:
return span{}, false
}
if n == 0 {
return span{}, false
}
start := uintptr(v.UnsafePointer())
return span{start, start + n}, true
}
func sourceGraph(v reflect.Value) (*graph, error) {
g := &graph{maps: map[uintptr]bool{}}
err := scanGraph(v, func(v reflect.Value, _ string) error {
if r, ok := mutableSpan(v); ok {
g.spans = append(g.spans, r)
}
if v.Kind() == reflect.Map && !v.IsNil() {
g.maps[uintptr(v.UnsafePointer())] = true
}
return nil
})
sort.Slice(g.spans, func(i, j int) bool { return g.spans[i].start < g.spans[j].start })
var end uintptr
for _, r := range g.spans {
if r.end > end {
end = r.end
}
g.maxEnd = append(g.maxEnd, end)
}
return g, err
}
func (g *graph) audit(v reflect.Value) error {
return scanGraph(v, func(v reflect.Value, path string) error {
if forbidden(v.Type()) {
return at(path, ErrUnsupported)
}
switch v.Kind() {
case reflect.Func, reflect.Chan:
if !v.IsNil() {
return at(path, ErrUnsupported)
}
case reflect.UnsafePointer:
if v.Pointer() != 0 {
return at(path, ErrUnsupported)
}
}
if v.Kind() == reflect.Map && !v.IsNil() && g.maps[uintptr(v.UnsafePointer())] {
return at(path, ErrSharedReference)
}
if r, ok := mutableSpan(v); ok {
i := sort.Search(len(g.spans), func(i int) bool { return g.spans[i].start >= r.end })
if i > 0 && g.maxEnd[i-1] > r.start {
return at(path, ErrSharedReference)
}
}
return nil
})
}
func forbidden(t reflect.Type) bool {
// Lock/resource implementations are not data. A custom converter may turn
// them into a different data type but may not manufacture cloned handles.
switch t.PkgPath() {
case "sync", "sync/atomic", "os", "net", "database/sql":
return t.Kind() == reflect.Struct
}
return false
}
// Shallow copies may share handles through pointers, but must not copy lock or
// resource state by value, including state inside a containing struct/array.
var safeValueTypes sync.Map
func shallowSafe(t reflect.Type) bool {
if known, ok := safeValueTypes.Load(t); ok {
return known.(bool)
}
result := true
if forbidden(t) {
safeValueTypes.Store(t, false)
return false
}
switch t.Kind() {
case reflect.Struct:
for i := 0; i < t.NumField(); i++ {
if !shallowSafe(t.Field(i).Type) {
result = false
break
}
}
case reflect.Array:
result = shallowSafe(t.Elem())
}
safeValueTypes.Store(t, result)
return result
}
func privateMutable(t reflect.Type) bool {
if t == timeType {
return false
}
switch t.Kind() {
case reflect.Pointer, reflect.Map, reflect.Slice, reflect.Interface, reflect.Func, reflect.Chan, reflect.UnsafePointer:
return true
case reflect.Array:
return privateMutable(t.Elem())
case reflect.Struct:
if forbidden(t) {
return true
}
for i := 0; i < t.NumField(); i++ {
if privateMutable(t.Field(i).Type) {
return true
}
}
}
return false
}

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
}