feat: add precise diagnostics and optional client copy logging controls

This commit is contained in:
2026-09-26 18:04:33 +08:00
parent 69585dab61
commit 3bbc6f60d7
44 changed files with 1529 additions and 84 deletions

192
copyx/check.go Normal file
View File

@@ -0,0 +1,192 @@
package copyx
import (
"errors"
"fmt"
"reflect"
"sort"
)
// ErrDynamicType means a source interface requires a concrete runtime value.
// An exact converter can explicitly own that dynamic boundary.
var ErrDynamicType = errors.New("copyx: dynamic source type requires runtime validation or an exact converter")
// Check validates a source/target type pair using the same options as Copy.
// It never executes converters and requires no example values. Nil policies,
// converter failures, map-key collisions and deep reference isolation still
// require runtime checks. Options remain local; no mapping files or registration.
func Check[S, T any](options ...Option) error {
c, err := New(options...)
if err != nil {
return err
}
return c.Check(reflect.TypeFor[S](), reflect.TypeFor[T]())
}
// Check validates types with an existing Copier's immutable settings. The target
// is the result type (not Copy's extra output pointer). Safe for concurrent use.
func (c *Copier) Check(source, target reflect.Type) error {
if c == nil {
return errors.New("copyx: nil Copier")
}
if source == nil || target == nil {
return errors.New("copyx: source and target types are required")
}
return newTypeChecker(&c.options, false).check(source, target, "$", 0)
}
type typeChecker struct {
options *settings
allowDynamic bool
seen map[pair]bool
nodes int
}
func newTypeChecker(options *settings, allowDynamic bool) *typeChecker {
return &typeChecker{options: options, allowDynamic: allowDynamic, seen: map[pair]bool{}}
}
// Absent elements can reveal type/field mismatches, but contain no actual
// resource handles or dynamic interface values to validate. Runtime checks stay
// responsible for those value-dependent cases.
func (s *state) checkAbsent(from, to reflect.Type, path string) error {
if s.checkedTypes == nil {
s.checkedTypes = map[pair]bool{}
}
checker := typeChecker{options: s.options, allowDynamic: true, seen: s.checkedTypes, nodes: s.checkedNodes}
err := checker.check(from, to, path, 0)
s.checkedNodes = checker.nodes
return err
}
func (c *typeChecker) check(from, to reflect.Type, path string, depth int) error {
c.nodes++
if depth > maxDepth || c.nodes > maxNodes {
return at(path, ErrLimit)
}
key := pair{from, to}
if c.seen[key] {
return nil
}
c.seen[key] = true
if !c.allowDynamic && !shallowSafe(to) {
return at(path, ErrUnsupported)
}
if _, ok := c.options.converters[key]; ok {
return nil
}
if from == to && len(c.options.converters) == 0 && len(c.options.fields) == 0 && !c.options.skipZero && c.options.nilPolicy != NilSkip {
if !c.options.deep || simpleValue(to) {
return nil
}
}
if from.Kind() == reflect.Interface {
if c.allowDynamic {
return nil
}
return at(path, ErrDynamicType)
}
if to.Kind() == reflect.Interface {
if !from.Implements(to) {
return at(path, ErrType)
}
return c.check(from, from, path, depth+1)
}
if !c.allowDynamic && c.options.deep && forbidden(to) {
return at(path, ErrUnsupported)
}
if from.Kind() == reflect.Pointer && to.Kind() != reflect.Pointer {
return c.check(from.Elem(), to, path, depth+1)
}
if to.Kind() == reflect.Pointer {
if !c.options.deep && from == to {
return nil
}
if from.Kind() == reflect.Pointer {
from = from.Elem()
}
return c.check(from, to.Elem(), path, depth+1)
}
if from.Kind() != to.Kind() {
return at(path, fmt.Errorf("%w: %s -> %s", ErrType, from, to))
}
if to == timeType && from == to {
return nil
}
switch to.Kind() {
case reflect.Struct:
for i := 0; i < to.NumField(); i++ {
f := to.Field(i)
if !c.allowDynamic && f.PkgPath != "" && (from != to || c.options.deep && privateMutable(f.Type)) {
return at(path+"."+f.Name, ErrUnsupported)
}
}
sources, targets := exportedFields(from), exportedFields(to)
rules := c.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 := range sortedFieldNames(targets) {
if rules.ignoreTarget[name] {
continue
}
sourceName := name
if n, ok := reverse[name]; ok {
sourceName = n
}
source, ok := sources[sourceName]
if !ok || rules.ignoreSource[sourceName] {
return at(path+"."+name, ErrField)
}
used[sourceName] = true
if err := c.check(source.Type, targets[name].Type, path+"."+name, depth+1); err != nil {
return err
}
}
if c.options.strict {
for _, name := range sortedFieldNames(sources) {
if !used[name] && !rules.ignoreSource[name] {
return at(path+"."+name, ErrField)
}
}
}
return nil
case reflect.Slice:
if !c.options.deep && from == to {
return nil
}
return c.check(from.Elem(), to.Elem(), path+"[]", depth+1)
case reflect.Array:
if from.Len() != to.Len() {
return at(path, ErrType)
}
return c.check(from.Elem(), to.Elem(), path+"[]", depth+1)
case reflect.Map:
if !c.options.deep && from == to {
return nil
}
if err := c.check(from.Key(), to.Key(), path+"[key]", depth+1); err != nil {
return err
}
return c.check(from.Elem(), to.Elem(), path+"[value]", depth+1)
case reflect.Func, reflect.Chan, reflect.UnsafePointer:
if c.options.deep && !c.allowDynamic {
return at(path, ErrUnsupported)
}
}
if from != to {
return at(path, fmt.Errorf("%w: %s -> %s", ErrType, from, to))
}
return nil
}
func sortedFieldNames(fields map[string]reflect.StructField) []string {
names := make([]string, 0, len(fields))
for name := range fields {
names = append(names, name)
}
sort.Strings(names)
return names
}

192
copyx/check_test.go Normal file
View File

@@ -0,0 +1,192 @@
package copyx_test
import (
"errors"
"reflect"
"strings"
"sync"
"testing"
"git.yuntuo.hk/k2safe/go-omni/copyx"
)
type checkSource struct{ Rows []checkSourceRow }
type checkSourceRow struct{ Value string }
type checkTarget struct{ Rows []checkTargetRow }
type checkTargetRow struct{ Value int }
type recursiveSource struct {
Children []*recursiveSource
Value string
}
type recursiveTarget struct {
Children []*recursiveTarget
Value int
}
type recursiveCompatible struct {
Children []*recursiveCompatible
Value string
}
func TestCheckTypesAndEmptyCollections(t *testing.T) {
err := copyx.Check[checkSource, checkTarget]()
var field *copyx.Error
if !errors.Is(err, copyx.ErrType) || !errors.As(err, &field) || field.Path != "$.Rows[].Value" {
t.Fatal(err)
}
for _, rows := range [][]checkSourceRow{nil, {}, {{Value: "42"}}} {
out := checkTarget{Rows: []checkTargetRow{{Value: 99}}}
if err := copyx.Copy(&out, checkSource{rows}); !errors.Is(err, copyx.ErrType) {
t.Fatalf("rows %v: %v", rows, err)
}
if len(out.Rows) != 1 || out.Rows[0].Value != 99 {
t.Fatal("partial target committed")
}
}
if err := copyx.Check[map[string]checkSourceRow, map[string]checkTargetRow](); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
var dst map[string]checkTargetRow
if err := copyx.Copy(&dst, map[string]checkSourceRow{}); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
var array [0]checkTargetRow
if err := copyx.Copy(&array, [0]checkSourceRow{}); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
if err := copyx.Check[map[string]int, map[int]int](); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
if err := copyx.Check[[2]int, [3]int](); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
if err := copyx.Check[*recursiveSource, *recursiveTarget](); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
if err := copyx.Check[*recursiveSource, *recursiveCompatible](copyx.WithDeepCopy()); err != nil {
t.Fatal(err)
}
}
func TestCheckRulesAndConverterBoundaries(t *testing.T) {
type from struct {
Name string
Extra bool
}
type to struct {
Label string
Keep int
}
options := []copyx.Option{copyx.WithStrictFields(), copyx.WithFieldNames[from, to](map[string]string{"Name": "Label"}), copyx.WithIgnoreSource[from, to]("Extra"), copyx.WithIgnoreTarget[from, to]("Keep")}
if err := copyx.Check[from, to](options...); err != nil {
t.Fatal(err)
}
if err := copyx.Check[from, to](); !errors.Is(err, copyx.ErrField) {
t.Fatal(err)
}
type short struct{ Name string }
if err := copyx.Check[from, short](copyx.WithStrictFields()); !errors.Is(err, copyx.ErrField) {
t.Fatal(err)
}
if err := copyx.Check[checkSource, checkTarget](copyx.WithConverter(func(string) (int, error) { panic("check must not execute converters") })); err != nil {
t.Fatal(err)
}
if err := copyx.Check[struct{ Value any }, struct{ Value int }](); !errors.Is(err, copyx.ErrDynamicType) {
t.Fatal(err)
}
if err := copyx.Check[any, int](copyx.WithConverter(func(any) (int, error) { panic("not called") })); err != nil {
t.Fatal(err)
}
if err := copyx.Check[*int, int](); err != nil {
t.Fatal(err)
} // Actual nil remains a runtime error.
var src *int
dst := 1
if err := copyx.Copy(&dst, src); !errors.Is(err, copyx.ErrNil) || dst != 1 {
t.Fatal(err)
}
var empty []int
if err := copyx.Copy(&empty, []any{}); err != nil || empty == nil {
t.Fatal("dynamic emptiness changed", err)
}
if err := copyx.Check[sync.Mutex, sync.Mutex](); !errors.Is(err, copyx.ErrUnsupported) {
t.Fatal(err)
}
option := copyx.WithConverter(func(v []int) ([]int, error) { return v, nil })
if err := copyx.Check[[]int, []int](copyx.WithDeepCopy(), option); err != nil {
t.Fatal(err)
}
if _, err := copyx.Clone([]int{1}, option); !errors.Is(err, copyx.ErrSharedReference) {
t.Fatal("type check bypassed graph audit", err)
}
}
func TestCopierCheckConcurrentAndOptionsIsolation(t *testing.T) {
good, _ := copyx.New(copyx.WithConverter(func(string) (int, error) { return 42, nil }))
bad, _ := copyx.New()
var wg sync.WaitGroup
for i := 0; i < 24; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 20; j++ {
if err := good.Check(reflect.TypeFor[checkSource](), reflect.TypeFor[checkTarget]()); err != nil {
t.Error(err)
}
if err := bad.Check(reflect.TypeFor[checkSource](), reflect.TypeFor[checkTarget]()); !errors.Is(err, copyx.ErrType) {
t.Error(err)
}
var target checkTarget
if err := good.Copy(&target, checkSource{Rows: []checkSourceRow{}}); err != nil || target.Rows == nil {
t.Error(err)
}
}
}()
}
wg.Wait()
if err := good.Check(nil, reflect.TypeFor[int]()); err == nil {
t.Fatal("nil type accepted")
}
var absent *copyx.Copier
if err := absent.Check(reflect.TypeFor[int](), reflect.TypeFor[int]()); err == nil {
t.Fatal("nil copier accepted")
}
if err := copyx.Check[int, string](); err == nil || !strings.Contains(err.Error(), "int -> string") {
t.Fatal(err)
}
}
func BenchmarkTypeCheck(b *testing.B) {
c, _ := copyx.New(copyx.WithConverter(func(string) (int, error) { return 1, nil }))
from, to := reflect.TypeFor[checkSource](), reflect.TypeFor[checkTarget]()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if err := c.Check(from, to); err != nil {
b.Fatal(err)
}
}
}
func TestEmptyTypeCheckPreservesAbsentValueSemantics(t *testing.T) {
var callback func()
if out, err := copyx.Clone(callback); err != nil || out != nil {
t.Fatal(err)
}
if out, err := copyx.Clone([]func(){}); err != nil || out == nil || len(out) != 0 {
t.Fatal(err)
}
if _, err := copyx.Clone([]func(){func() {}}); !errors.Is(err, copyx.ErrUnsupported) {
t.Fatal(err)
}
if err := copyx.Check[[]func(), []func()](copyx.WithDeepCopy()); !errors.Is(err, copyx.ErrUnsupported) {
t.Fatal("preflight must report potentially non-nil handles", err)
}
type from struct {
Ignored *int
Value string
}
type to struct {
Ignored *int
Value int
}
var dst []to
if err := copyx.Copy(&dst, []from{}, copyx.WithDeepCopy()); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
}

View File

@@ -89,12 +89,14 @@ type visit struct {
target reflect.Type
}
type state struct {
options *settings
seen map[visit]reflect.Value
graph *graph
nodes int
allocated uint64
valueRoot *reference
options *settings
seen map[visit]reflect.Value
graph *graph
nodes int
allocated uint64
valueRoot *reference
checkedTypes map[pair]bool
checkedNodes int
}
func (s *state) reserve(count int, size uintptr, path string) error {
@@ -239,6 +241,11 @@ func (s *state) walk(src, base reflect.Value, t reflect.Type, path string, depth
case reflect.Struct:
return s.structure(src, base, t, path, depth)
case reflect.Slice:
if src.Len() == 0 {
if err := s.checkAbsent(src.Type(), t, path); err != nil {
return reflect.Value{}, err
}
}
if !s.options.deep && src.Type() == t {
return src, nil
}
@@ -264,6 +271,11 @@ func (s *state) walk(src, base reflect.Value, t reflect.Type, path string, depth
}
return out, nil
case reflect.Array:
if src.Len() == 0 {
if err := s.checkAbsent(src.Type(), t, path); err != nil {
return reflect.Value{}, err
}
}
if src.Len() != t.Len() {
return reflect.Value{}, at(path, ErrType)
}
@@ -280,6 +292,11 @@ func (s *state) walk(src, base reflect.Value, t reflect.Type, path string, depth
}
return out, nil
case reflect.Map:
if src.Len() == 0 {
if err := s.checkAbsent(src.Type(), t, path); err != nil {
return reflect.Value{}, err
}
}
if !s.options.deep && src.Type() == t {
return src, nil
}