Retain location metadata for values in `convert.FromTyped` (#1523)

## Changes

There are four different treatments location metadata can receive in the
`convert.FromTyped` method.

1. Location metadata is **retained** for maps, structs and slices if the
value is **not nil**
2. Location metadata is **lost** for maps, structs and slices if the
value is **is nil**
3. Location metadata is **retained** if a scalar type (eg. bool, string
etc) does not change.
4. Location metadata is **lost** if the value for a scalar type changes.

This PR ensures that location metadata is not lost in any case; that is,
it's always preserved.

For (2), this serves as a bug fix so that location information is not
lost on conversion to and from typed for nil values of complex types
(struct, slices, and maps).

For (4) this is a change in semantics. For primitive values modified in
a `typed` mutator, any references to `.Location()` for computed
primitive fields will now return associated YAML location metadata (if
any) instead of an empty location.

While arguable, these semantics are OK since:
1. Situations like these will be rare.
2. Knowing the YAML location (if any) is better than not knowing the
location at all. These locations are typically visible to the user in
errors and warnings.

## Tests

Unit tests
This commit is contained in:
shreyas-goenka 2024-06-25 19:10:21 +05:30 committed by GitHub
parent 8468878eed
commit dac5f09556
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 105 additions and 38 deletions

View File

@ -42,7 +42,7 @@ func fromTyped(src any, ref dyn.Value, options ...fromTypedOptions) (dyn.Value,
// Dereference pointer if necessary // Dereference pointer if necessary
for srcv.Kind() == reflect.Pointer { for srcv.Kind() == reflect.Pointer {
if srcv.IsNil() { if srcv.IsNil() {
return dyn.NilValue, nil return dyn.NilValue.WithLocation(ref.Location()), nil
} }
srcv = srcv.Elem() srcv = srcv.Elem()
@ -55,27 +55,35 @@ func fromTyped(src any, ref dyn.Value, options ...fromTypedOptions) (dyn.Value,
} }
} }
var v dyn.Value
var err error
switch srcv.Kind() { switch srcv.Kind() {
case reflect.Struct: case reflect.Struct:
return fromTypedStruct(srcv, ref, options...) v, err = fromTypedStruct(srcv, ref, options...)
case reflect.Map: case reflect.Map:
return fromTypedMap(srcv, ref) v, err = fromTypedMap(srcv, ref)
case reflect.Slice: case reflect.Slice:
return fromTypedSlice(srcv, ref) v, err = fromTypedSlice(srcv, ref)
case reflect.String: case reflect.String:
return fromTypedString(srcv, ref, options...) v, err = fromTypedString(srcv, ref, options...)
case reflect.Bool: case reflect.Bool:
return fromTypedBool(srcv, ref, options...) v, err = fromTypedBool(srcv, ref, options...)
case reflect.Int, reflect.Int32, reflect.Int64: case reflect.Int, reflect.Int32, reflect.Int64:
return fromTypedInt(srcv, ref, options...) v, err = fromTypedInt(srcv, ref, options...)
case reflect.Float32, reflect.Float64: case reflect.Float32, reflect.Float64:
return fromTypedFloat(srcv, ref, options...) v, err = fromTypedFloat(srcv, ref, options...)
case reflect.Invalid: case reflect.Invalid:
// If the value is untyped and not set (e.g. any type with nil value), we return nil. // If the value is untyped and not set (e.g. any type with nil value), we return nil.
return dyn.NilValue, nil v, err = dyn.NilValue, nil
default:
return dyn.InvalidValue, fmt.Errorf("unsupported type: %s", srcv.Kind())
} }
return dyn.InvalidValue, fmt.Errorf("unsupported type: %s", srcv.Kind()) // Ensure the location metadata is retained.
if err != nil {
return dyn.InvalidValue, err
}
return v.WithLocation(ref.Location()), err
} }
func fromTypedStruct(src reflect.Value, ref dyn.Value, options ...fromTypedOptions) (dyn.Value, error) { func fromTypedStruct(src reflect.Value, ref dyn.Value, options ...fromTypedOptions) (dyn.Value, error) {
@ -117,7 +125,7 @@ func fromTypedStruct(src reflect.Value, ref dyn.Value, options ...fromTypedOptio
// 2. The reference is a map (i.e. the struct was and still is empty). // 2. The reference is a map (i.e. the struct was and still is empty).
// 3. The "includeZeroValues" option is set (i.e. the struct is a non-nil pointer). // 3. The "includeZeroValues" option is set (i.e. the struct is a non-nil pointer).
if out.Len() > 0 || ref.Kind() == dyn.KindMap || slices.Contains(options, includeZeroValues) { if out.Len() > 0 || ref.Kind() == dyn.KindMap || slices.Contains(options, includeZeroValues) {
return dyn.NewValue(out, ref.Location()), nil return dyn.V(out), nil
} }
// Otherwise, return nil. // Otherwise, return nil.
@ -164,7 +172,7 @@ func fromTypedMap(src reflect.Value, ref dyn.Value) (dyn.Value, error) {
out.Set(refk, nv) out.Set(refk, nv)
} }
return dyn.NewValue(out, ref.Location()), nil return dyn.V(out), nil
} }
func fromTypedSlice(src reflect.Value, ref dyn.Value) (dyn.Value, error) { func fromTypedSlice(src reflect.Value, ref dyn.Value) (dyn.Value, error) {
@ -199,7 +207,7 @@ func fromTypedSlice(src reflect.Value, ref dyn.Value) (dyn.Value, error) {
out[i] = nv out[i] = nv
} }
return dyn.NewValue(out, ref.Location()), nil return dyn.V(out), nil
} }
func fromTypedString(src reflect.Value, ref dyn.Value, options ...fromTypedOptions) (dyn.Value, error) { func fromTypedString(src reflect.Value, ref dyn.Value, options ...fromTypedOptions) (dyn.Value, error) {

View File

@ -49,7 +49,7 @@ func TestFromTypedStructPointerZeroFields(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, dyn.NilValue, nv) assert.Equal(t, dyn.NilValue, nv)
// For an initialized pointer with a nil reference we expect a nil. // For an initialized pointer with a nil reference we expect an empty map.
src = &Tmp{} src = &Tmp{}
nv, err = FromTyped(src, dyn.NilValue) nv, err = FromTyped(src, dyn.NilValue)
require.NoError(t, err) require.NoError(t, err)
@ -103,7 +103,7 @@ func TestFromTypedStructSetFields(t *testing.T) {
}), nv) }), nv)
} }
func TestFromTypedStructSetFieldsRetainLocationIfUnchanged(t *testing.T) { func TestFromTypedStructSetFieldsRetainLocation(t *testing.T) {
type Tmp struct { type Tmp struct {
Foo string `json:"foo"` Foo string `json:"foo"`
Bar string `json:"bar"` Bar string `json:"bar"`
@ -122,11 +122,9 @@ func TestFromTypedStructSetFieldsRetainLocationIfUnchanged(t *testing.T) {
nv, err := FromTyped(src, ref) nv, err := FromTyped(src, ref)
require.NoError(t, err) require.NoError(t, err)
// Assert foo has retained its location. // Assert foo and bar have retained their location.
assert.Equal(t, dyn.NewValue("bar", dyn.Location{File: "foo"}), nv.Get("foo")) assert.Equal(t, dyn.NewValue("bar", dyn.Location{File: "foo"}), nv.Get("foo"))
assert.Equal(t, dyn.NewValue("qux", dyn.Location{File: "bar"}), nv.Get("bar"))
// Assert bar lost its location (because it was overwritten).
assert.Equal(t, dyn.NewValue("qux", dyn.Location{}), nv.Get("bar"))
} }
func TestFromTypedStringMapWithZeroValue(t *testing.T) { func TestFromTypedStringMapWithZeroValue(t *testing.T) {
@ -354,7 +352,7 @@ func TestFromTypedMapNonEmpty(t *testing.T) {
}), nv) }), nv)
} }
func TestFromTypedMapNonEmptyRetainLocationIfUnchanged(t *testing.T) { func TestFromTypedMapNonEmptyRetainLocation(t *testing.T) {
var src = map[string]string{ var src = map[string]string{
"foo": "bar", "foo": "bar",
"bar": "qux", "bar": "qux",
@ -368,11 +366,9 @@ func TestFromTypedMapNonEmptyRetainLocationIfUnchanged(t *testing.T) {
nv, err := FromTyped(src, ref) nv, err := FromTyped(src, ref)
require.NoError(t, err) require.NoError(t, err)
// Assert foo has retained its location. // Assert foo and bar have retained their locations.
assert.Equal(t, dyn.NewValue("bar", dyn.Location{File: "foo"}), nv.Get("foo")) assert.Equal(t, dyn.NewValue("bar", dyn.Location{File: "foo"}), nv.Get("foo"))
assert.Equal(t, dyn.NewValue("qux", dyn.Location{File: "bar"}), nv.Get("bar"))
// Assert bar lost its location (because it was overwritten).
assert.Equal(t, dyn.NewValue("qux", dyn.Location{}), nv.Get("bar"))
} }
func TestFromTypedMapFieldWithZeroValue(t *testing.T) { func TestFromTypedMapFieldWithZeroValue(t *testing.T) {
@ -429,7 +425,7 @@ func TestFromTypedSliceNonEmpty(t *testing.T) {
}), nv) }), nv)
} }
func TestFromTypedSliceNonEmptyRetainLocationIfUnchanged(t *testing.T) { func TestFromTypedSliceNonEmptyRetainLocation(t *testing.T) {
var src = []string{ var src = []string{
"foo", "foo",
"bar", "bar",
@ -437,17 +433,15 @@ func TestFromTypedSliceNonEmptyRetainLocationIfUnchanged(t *testing.T) {
ref := dyn.V([]dyn.Value{ ref := dyn.V([]dyn.Value{
dyn.NewValue("foo", dyn.Location{File: "foo"}), dyn.NewValue("foo", dyn.Location{File: "foo"}),
dyn.NewValue("baz", dyn.Location{File: "baz"}), dyn.NewValue("bar", dyn.Location{File: "bar"}),
}) })
nv, err := FromTyped(src, ref) nv, err := FromTyped(src, ref)
require.NoError(t, err) require.NoError(t, err)
// Assert foo has retained its location. // Assert foo and bar have retained their locations.
assert.Equal(t, dyn.NewValue("foo", dyn.Location{File: "foo"}), nv.Index(0)) assert.Equal(t, dyn.NewValue("foo", dyn.Location{File: "foo"}), nv.Index(0))
assert.Equal(t, dyn.NewValue("bar", dyn.Location{File: "bar"}), nv.Index(1))
// Assert bar lost its location (because it was overwritten).
assert.Equal(t, dyn.NewValue("bar", dyn.Location{}), nv.Index(1))
} }
func TestFromTypedStringEmpty(t *testing.T) { func TestFromTypedStringEmpty(t *testing.T) {
@ -482,12 +476,20 @@ func TestFromTypedStringNonEmptyOverwrite(t *testing.T) {
assert.Equal(t, dyn.V("new"), nv) assert.Equal(t, dyn.V("new"), nv)
} }
func TestFromTypedStringRetainsLocationsIfUnchanged(t *testing.T) { func TestFromTypedStringRetainsLocations(t *testing.T) {
var src string = "foo"
var ref = dyn.NewValue("foo", dyn.Location{File: "foo"}) var ref = dyn.NewValue("foo", dyn.Location{File: "foo"})
// case: value has not been changed
var src string = "foo"
nv, err := FromTyped(src, ref) nv, err := FromTyped(src, ref)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, dyn.NewValue("foo", dyn.Location{File: "foo"}), nv) assert.Equal(t, dyn.NewValue("foo", dyn.Location{File: "foo"}), nv)
// case: value has been changed
src = "bar"
nv, err = FromTyped(src, ref)
require.NoError(t, err)
assert.Equal(t, dyn.NewValue("bar", dyn.Location{File: "foo"}), nv)
} }
func TestFromTypedStringTypeError(t *testing.T) { func TestFromTypedStringTypeError(t *testing.T) {
@ -529,12 +531,20 @@ func TestFromTypedBoolNonEmptyOverwrite(t *testing.T) {
assert.Equal(t, dyn.V(true), nv) assert.Equal(t, dyn.V(true), nv)
} }
func TestFromTypedBoolRetainsLocationsIfUnchanged(t *testing.T) { func TestFromTypedBoolRetainsLocations(t *testing.T) {
var src bool = true
var ref = dyn.NewValue(true, dyn.Location{File: "foo"}) var ref = dyn.NewValue(true, dyn.Location{File: "foo"})
// case: value has not been changed
var src bool = true
nv, err := FromTyped(src, ref) nv, err := FromTyped(src, ref)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, dyn.NewValue(true, dyn.Location{File: "foo"}), nv) assert.Equal(t, dyn.NewValue(true, dyn.Location{File: "foo"}), nv)
// case: value has been changed
src = false
nv, err = FromTyped(src, ref)
require.NoError(t, err)
assert.Equal(t, dyn.NewValue(false, dyn.Location{File: "foo"}), nv)
} }
func TestFromTypedBoolVariableReference(t *testing.T) { func TestFromTypedBoolVariableReference(t *testing.T) {
@ -584,12 +594,20 @@ func TestFromTypedIntNonEmptyOverwrite(t *testing.T) {
assert.Equal(t, dyn.V(int64(1234)), nv) assert.Equal(t, dyn.V(int64(1234)), nv)
} }
func TestFromTypedIntRetainsLocationsIfUnchanged(t *testing.T) { func TestFromTypedIntRetainsLocations(t *testing.T) {
var src int = 1234
var ref = dyn.NewValue(1234, dyn.Location{File: "foo"}) var ref = dyn.NewValue(1234, dyn.Location{File: "foo"})
// case: value has not been changed
var src int = 1234
nv, err := FromTyped(src, ref) nv, err := FromTyped(src, ref)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, dyn.NewValue(1234, dyn.Location{File: "foo"}), nv) assert.Equal(t, dyn.NewValue(1234, dyn.Location{File: "foo"}), nv)
// case: value has been changed
src = 1235
nv, err = FromTyped(src, ref)
require.NoError(t, err)
assert.Equal(t, dyn.NewValue(int64(1235), dyn.Location{File: "foo"}), nv)
} }
func TestFromTypedIntVariableReference(t *testing.T) { func TestFromTypedIntVariableReference(t *testing.T) {
@ -639,12 +657,21 @@ func TestFromTypedFloatNonEmptyOverwrite(t *testing.T) {
assert.Equal(t, dyn.V(1.23), nv) assert.Equal(t, dyn.V(1.23), nv)
} }
func TestFromTypedFloatRetainsLocationsIfUnchanged(t *testing.T) { func TestFromTypedFloatRetainsLocations(t *testing.T) {
var src float64 = 1.23 var src float64
var ref = dyn.NewValue(1.23, dyn.Location{File: "foo"}) var ref = dyn.NewValue(1.23, dyn.Location{File: "foo"})
// case: value has not been changed
src = 1.23
nv, err := FromTyped(src, ref) nv, err := FromTyped(src, ref)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, dyn.NewValue(1.23, dyn.Location{File: "foo"}), nv) assert.Equal(t, dyn.NewValue(1.23, dyn.Location{File: "foo"}), nv)
// case: value has been changed
src = 1.24
nv, err = FromTyped(src, ref)
require.NoError(t, err)
assert.Equal(t, dyn.NewValue(1.24, dyn.Location{File: "foo"}), nv)
} }
func TestFromTypedFloatVariableReference(t *testing.T) { func TestFromTypedFloatVariableReference(t *testing.T) {
@ -669,3 +696,35 @@ func TestFromTypedAnyNil(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, dyn.NilValue, nv) assert.Equal(t, dyn.NilValue, nv)
} }
func TestFromTypedNilPointerRetainsLocations(t *testing.T) {
type Tmp struct {
Foo string `json:"foo"`
Bar string `json:"bar"`
}
var src *Tmp
ref := dyn.NewValue(nil, dyn.Location{File: "foobar"})
nv, err := FromTyped(src, ref)
require.NoError(t, err)
assert.Equal(t, dyn.NewValue(nil, dyn.Location{File: "foobar"}), nv)
}
func TestFromTypedNilMapRetainsLocation(t *testing.T) {
var src map[string]string
ref := dyn.NewValue(nil, dyn.Location{File: "foobar"})
nv, err := FromTyped(src, ref)
require.NoError(t, err)
assert.Equal(t, dyn.NewValue(nil, dyn.Location{File: "foobar"}), nv)
}
func TestFromTypedNilSliceRetainsLocation(t *testing.T) {
var src []string
ref := dyn.NewValue(nil, dyn.Location{File: "foobar"})
nv, err := FromTyped(src, ref)
require.NoError(t, err)
assert.Equal(t, dyn.NewValue(nil, dyn.Location{File: "foobar"}), nv)
}