Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions builtin/lib.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions test/issues/952/issue_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
10 changes: 7 additions & 3 deletions vm/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
}
Expand Down
2 changes: 1 addition & 1 deletion vm/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down