diff --git a/builtin/lib.go b/builtin/lib.go index 61748da08..2b63643c0 100644 --- a/builtin/lib.go +++ b/builtin/lib.go @@ -614,6 +614,12 @@ func get(params ...any) (out any, err error) { return value.Interface(), nil } } + // Field isn't found via standard Go promotion. Try to find it + // by traversing embedded interface values whose concrete types + // may contain the requested field, mirroring runtime.Fetch. + if result, found := runtime.FetchFromEmbeddedInterfaces(v, fieldName); found { + return result, nil + } } // Main difference from runtime.Fetch diff --git a/test/issues/952/issue_test.go b/test/issues/952/issue_test.go new file mode 100644 index 000000000..55d76a5c5 --- /dev/null +++ b/test/issues/952/issue_test.go @@ -0,0 +1,57 @@ +package issue952 + +import ( + "testing" + + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/internal/testify/require" +) + +// Node is an exported interface embedded into Wrapper below. Field access on the +// concrete type behind it is resolved by runtime.Fetch since #952. +type Node interface { + ID() string +} + +type Leaf struct { + Name string + Value string +} + +func (l Leaf) ID() string { return l.Name } + +// Wrapper embeds the Node interface; the concrete value behind it (a Leaf) has +// a Value field that is not promoted by Go's standard field promotion. +type Wrapper struct { + Node +} + +// TestGetEmbeddedInterfaceField guards against a regression where the builtin +// get() resolved struct fields inconsistently with member access (a.field). +// +// #952 taught runtime.Fetch to resolve fields on the concrete type held by an +// embedded interface, but left the sibling builtin get() behind, so the same +// field access returned the value through member access yet nil through get(). +// get() is documented to differ from runtime.Fetch only in returning nil +// instead of panicking, so the two must agree on which field is found. +func TestGetEmbeddedInterfaceField(t *testing.T) { + w := Wrapper{Node: Leaf{Name: "n1", Value: "hello"}} + env := map[string]any{"w": w} + + // Member access resolves the field via the concrete type behind the + // embedded interface. The ternary keeps the type unknown at compile time + // so the access goes through dynamic OpFetch (runtime.Fetch). + out, err := expr.Eval(`(true ? w : "x").Value`, env) + require.NoError(t, err) + require.Equal(t, "hello", out) + + // get() must resolve the same field to the same value rather than nil. + out, err = expr.Eval(`get((true ? w : "x"), "Value")`, env) + require.NoError(t, err) + require.Equal(t, "hello", out) + + // A genuinely missing field still yields nil from get() (not an error). + out, err = expr.Eval(`get((true ? w : "x"), "Missing")`, env) + require.NoError(t, err) + require.Nil(t, out) +} diff --git a/vm/runtime/runtime.go b/vm/runtime/runtime.go index b5aaa5ed8..9380e9014 100644 --- a/vm/runtime/runtime.go +++ b/vm/runtime/runtime.go @@ -89,7 +89,7 @@ func Fetch(from, i any) any { // Field isn't found via standard Go promotion. Try to find it // by traversing embedded interface values whose concrete types // may contain the requested field. - if result, found := fetchFromEmbeddedInterfaces(v, fieldName); found { + if result, found := FetchFromEmbeddedInterfaces(v, fieldName); found { return result } } @@ -160,7 +160,11 @@ func findStructField(v reflect.Value, fieldName string) (reflect.Value, reflect. return reflect.Value{}, reflect.StructField{}, false } -func fetchFromEmbeddedInterfaces(v reflect.Value, fieldName string) (any, bool) { +// FetchFromEmbeddedInterfaces looks up fieldName on the concrete types held by +// anonymous (embedded) interface fields of the struct value v, recursing through +// embedded structs to reach further embedded interfaces. It is shared by Fetch +// and the builtin get() so that both resolve such fields consistently. +func FetchFromEmbeddedInterfaces(v reflect.Value, fieldName string) (any, bool) { t := v.Type() for i := 0; i < t.NumField(); i++ { f := t.Field(i) @@ -180,7 +184,7 @@ func fetchFromEmbeddedInterfaces(v reflect.Value, fieldName string) (any, bool) return value.Interface(), true } } - if result, found := fetchFromEmbeddedInterfaces(fv, fieldName); found { + if result, found := FetchFromEmbeddedInterfaces(fv, fieldName); found { return result, true } } diff --git a/vm/runtime/runtime_test.go b/vm/runtime/runtime_test.go index 14fbff5d9..14fa376f6 100644 --- a/vm/runtime/runtime_test.go +++ b/vm/runtime/runtime_test.go @@ -163,7 +163,7 @@ func TestFetchFromEmbeddedInterfaces(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, ok := fetchFromEmbeddedInterfaces(reflect.ValueOf(tt.input), tt.fieldName) + got, ok := FetchFromEmbeddedInterfaces(reflect.ValueOf(tt.input), tt.fieldName) require.Equal(t, tt.ok, ok) if tt.ok { require.Equal(t, tt.want, got)