Skip to content

Commit

Permalink
Set destination struct field nil on zero after copying (#28)
Browse files Browse the repository at this point in the history
  • Loading branch information
tiendc authored Feb 8, 2025
1 parent a2fa5d1 commit 6cb02d9
Show file tree
Hide file tree
Showing 5 changed files with 333 additions and 44 deletions.
44 changes: 41 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
- Ability to copy between `pointers` and `values` (for example: copy from `*int` to `int`)
- Ability to copy struct fields via struct methods
- Ability to copy inherited fields from embedded structs
- Ability to set a destination struct field as `nil` if it is `zero`
- Ability to copy unexported struct fields
- Ability to configure copying behavior
- Ability to configure extra copying behaviors

## Installation

Expand All @@ -27,8 +28,9 @@ go get github.com/tiendc/go-deepcopy
- [Skip copying struct fields](#skip-copying-struct-fields)
- [Copy struct fields via struct methods](#copy-struct-fields-via-struct-methods)
- [Copy inherited fields from embedded structs](#copy-inherited-fields-from-embedded-structs)
- [Set destination struct fields as `nil` on `zero`](#set-destination-struct-fields-as-nil-on-zero)
- [Copy unexported struct fields](#copy-unexported-struct-fields)
- [Configure copying behavior](#configure-copying-behavior)
- [Configure extra copying behaviors](#configure-extra-copying-behaviors)

### First example

Expand Down Expand Up @@ -199,6 +201,42 @@ to achieve the same result.
// {I:11 St:xyz}
```

### Set destination struct fields as `nil` on `zero`

- This is a new feature from version 2.0. This applies to destination fields of type `pointer`, `interface`,
`slice`, and `map`. When their values are zero after copying, they will be set as `nil`. This is very
convenient when you don't want to send something like a date of `0001-01-01` to client, you want to send
`null` instead.

[Playground 1](https://go.dev/play/p/GO6VExVOLei) /
[Playground 2](https://go.dev/play/p/u0zMHx9UWjA) /
[Playground 3](https://go.dev/play/p/ZpA8DkQ9-7f)

```go
// Source struct has a time.Time field
type S struct {
I int
Time time.Time
}
// Destination field must be a nullable value such as `*time.Time` or `interface{}`
type D struct {
I int
Time *time.Time `copy:",nilonzero"` // make sure to use this tag
}

src := []S{{I: 1, Time: time.Time{}}, {I: 11, Time: time.Now()}}
var dst []D
_ = deepcopy.Copy(&dst, &src)

for _, d := range dst {
fmt.Printf("%+v\n", d)
}

// Output:
// {I:1 Time:<nil>} (source is a zero time value, destination becomes `nil`)
// {I:11 Time:2025-02-08 12:31:11...} (source is not zero, so be the destination)
```

### Copy unexported struct fields

- By default, unexported struct fields will be ignored when copy. If you want to copy them, use tag attribute `required`.
Expand Down Expand Up @@ -228,7 +266,7 @@ to achieve the same result.
// {i:11 U:22}
```

### Configure copying behavior
### Configure extra copying behaviors

- Not allow to copy between `ptr` type and `value` (default is `allow`)

Expand Down
59 changes: 24 additions & 35 deletions struct_copier.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,22 +238,24 @@ func (c *structCopier) createField2MethodCopier(dM *reflect.Method, sfDetail *fi

func (c *structCopier) createField2FieldCopier(df, sf *fieldDetail, cp copier) copier {
return &structField2FieldCopier{
copier: cp,
dstFieldIndex: df.index,
dstFieldUnexported: !df.field.IsExported(),
srcFieldIndex: sf.index,
srcFieldUnexported: !sf.field.IsExported(),
copier: cp,
dstFieldIndex: df.index,
dstFieldUnexported: !df.field.IsExported(),
dstFieldSetNilOnZero: df.nilOnZero,
srcFieldIndex: sf.index,
srcFieldUnexported: !sf.field.IsExported(),
}
}

// structFieldDirectCopier data structure of copier that copies from
// a src field to a dst field directly
type structField2FieldCopier struct {
copier copier
dstFieldIndex []int
dstFieldUnexported bool
srcFieldIndex []int
srcFieldUnexported bool
copier copier
dstFieldIndex []int
dstFieldUnexported bool
dstFieldSetNilOnZero bool
srcFieldIndex []int
srcFieldUnexported bool
}

// Copy implementation of Copy function for struct field copier direct.
Expand All @@ -267,7 +269,7 @@ func (c *structField2FieldCopier) Copy(dst, src reflect.Value) (err error) {
src, err = src.FieldByIndexErr(c.srcFieldIndex)
if err != nil {
// There's no src field to copy from, reset the dst field to zero
c.setFieldZero(dst, c.dstFieldIndex)
structFieldSetZero(dst, c.dstFieldIndex)
return nil //nolint:nilerr
}
}
Expand All @@ -283,7 +285,7 @@ func (c *structField2FieldCopier) Copy(dst, src reflect.Value) (err error) {
dst = dst.Field(c.dstFieldIndex[0])
} else {
// Get dst field with making sure it's settable
dst = c.getFieldWithInit(dst, c.dstFieldIndex)
dst = structFieldGetWithInit(dst, c.dstFieldIndex)
}
if c.dstFieldUnexported {
if !dst.CanAddr() {
Expand All @@ -295,33 +297,20 @@ func (c *structField2FieldCopier) Copy(dst, src reflect.Value) (err error) {

// Use custom copier if set
if c.copier != nil {
return c.copier.Copy(dst, src)
}
// Otherwise, just perform simple direct copying
dst.Set(src)
return nil
}

// getFieldWithInit gets deep nested field with init value for pointer ones
func (c *structField2FieldCopier) getFieldWithInit(field reflect.Value, index []int) reflect.Value {
for _, idx := range index {
if field.Kind() == reflect.Pointer {
if field.IsNil() {
field.Set(reflect.New(field.Type().Elem()))
}
field = field.Elem()
if err = c.copier.Copy(dst, src); err != nil {
return err
}
field = field.Field(idx)
} else {
// Otherwise, just perform simple direct copying
dst.Set(src)
}
return field
}

// setFieldZero sets zero to a deep nested field
func (c *structField2FieldCopier) setFieldZero(field reflect.Value, index []int) {
field, err := field.FieldByIndexErr(index)
if err == nil && field.IsValid() {
field.Set(reflect.Zero(field.Type())) // NOTE: Go1.18 has no SetZero
// When instructed to set `dst` as `nil` on zero
if c.dstFieldSetNilOnZero {
nillableValueSetNilOnZero(dst)
}

return nil
}

// structField2MethodCopier data structure of copier that copies between `fields` and `methods`
Expand Down
202 changes: 202 additions & 0 deletions struct_copier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package deepcopy

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
)
Expand Down Expand Up @@ -865,3 +866,204 @@ func Test_Copy_struct_with_embedded_struct_error(t *testing.T) {
assert.ErrorIs(t, err, ErrFieldRequireCopying)
})
}

func Test_Copy_struct_with_set_nil_on_zero(t *testing.T) {
t.Run("#1: primitive type", func(t *testing.T) {
type SS struct {
I int
U uint
}
type DD struct {
I int
U *uint `copy:",nilonzero"`
}

// Source field is not zero
s := SS{I: 1, U: 11}
d := DD{}
err := Copy(&d, s)
assert.Nil(t, err)
assert.Equal(t, DD{I: 1, U: ptrOf(uint(11))}, d)

// Source field is zero
s = SS{I: 1, U: 0}
d = DD{}
err = Copy(&d, s)
assert.Nil(t, err)
assert.Equal(t, DD{I: 1, U: nil}, d)
})

t.Run("#2: string type", func(t *testing.T) {
type SS struct {
I int
S string
}
type DD struct {
I int
S *string `copy:",nilonzero"`
}

// Source field is not zero
s := SS{I: 1, S: "x"}
d := DD{}
err := Copy(&d, s)
assert.Nil(t, err)
assert.Equal(t, DD{I: 1, S: ptrOf("x")}, d)

// Source field is zero
s = SS{I: 1, S: ""}
d = DD{}
err = Copy(&d, s)
assert.Nil(t, err)
assert.Equal(t, DD{I: 1, S: nil}, d)
})

t.Run("#3: time.Time type", func(t *testing.T) {
type SS struct {
I int
Time time.Time
}
type DD struct {
I int
Time *time.Time `copy:",nilonzero"`
}

// Source field is not zero
dt := time.Now()
s := SS{I: 1, Time: dt}
d := DD{}
err := Copy(&d, &s)
assert.Nil(t, err)
assert.Equal(t, DD{I: 1, Time: ptrOf(dt)}, d)

// Source field is zero
s = SS{I: 1, Time: time.Time{}}
d = DD{}
err = Copy(&d, &s)
assert.Nil(t, err)
assert.Equal(t, DD{I: 1, Time: nil}, d)
})

t.Run("#3b: time.Time type with deeper level", func(t *testing.T) {
type SS struct {
I int
Time time.Time
}
type DD struct {
I int
Time ***time.Time `copy:",nilonzero"`
}

// Source field is not zero
dt := time.Now()
s := SS{I: 1, Time: dt}
d := DD{}
err := Copy(&d, &s)
assert.Nil(t, err)
assert.Equal(t, DD{I: 1, Time: ptrOf(ptrOf(ptrOf(dt)))}, d)

// Source field is zero
s = SS{I: 1, Time: time.Time{}}
d = DD{}
err = Copy(&d, &s)
assert.Nil(t, err)
assert.Equal(t, DD{I: 1, Time: nil}, d)
})

t.Run("#4: custom struct type", func(t *testing.T) {
type StructType struct {
I int
S *string
}
type SS struct {
I int
ST StructType
}
type DD struct {
I int
ST *StructType `copy:",nilonzero"`
}

s := SS{I: 1, ST: StructType{}}
d := DD{}
err := Copy(&d, &s)
assert.Nil(t, err)
assert.Equal(t, DD{I: 1, ST: nil}, d)
})

t.Run("#5: dst field is interface type", func(t *testing.T) {
type SS struct {
I int
Time time.Time
}
type DD struct {
I int
Time any `copy:",nilonzero"`
}

// Source field is not zero
dt := time.Now()
s := SS{I: 1, Time: dt}
d := DD{}
err := Copy(&d, &s)
assert.Nil(t, err)
assert.Equal(t, DD{I: 1, Time: dt}, d)

// Source field is zero
s = SS{I: 1, Time: time.Time{}}
d = DD{}
err = Copy(&d, &s)
assert.Nil(t, err)
assert.Equal(t, DD{I: 1, Time: nil}, d)
})

t.Run("#6: dst field is map type", func(t *testing.T) {
type SS struct {
I int
M map[int]string
}
type DD struct {
I int
M map[int]string `copy:",nilonzero"`
}

// Source field is not zero
s := SS{I: 1, M: map[int]string{1: "a", 2: "b"}}
d := DD{}
err := Copy(&d, &s)
assert.Nil(t, err)
assert.Equal(t, DD{I: 1, M: map[int]string{1: "a", 2: "b"}}, d)

// Source field is zero
s = SS{I: 1, M: map[int]string{}}
d = DD{}
err = Copy(&d, &s)
assert.Nil(t, err)
assert.Equal(t, DD{I: 1, M: nil}, d)
})

t.Run("#7: dst field is slice type", func(t *testing.T) {
type SS struct {
I int
S []any
}
type DD struct {
I int
S []any `copy:",nilonzero"`
}

// Source field is not zero
s := SS{I: 1, S: []any{1, "a"}}
d := DD{}
err := Copy(&d, &s)
assert.Nil(t, err)
assert.Equal(t, DD{I: 1, S: []any{1, "a"}}, d)

// Source field is zero
s = SS{I: 1, S: make([]any, 0, 10)}
d = DD{}
err = Copy(&d, &s)
assert.Nil(t, err)
assert.Equal(t, DD{I: 1, S: nil}, d)
})
}
Loading

0 comments on commit 6cb02d9

Please sign in to comment.