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
32 changes: 32 additions & 0 deletions test/issues/990/issue_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package issue990

import (
"testing"

"github.com/expr-lang/expr"
"github.com/expr-lang/expr/internal/testify/require"
)

type Outer struct {
Inner map[string]struct{} `expr:"bar"`
}

type Env struct {
Outer Outer `expr:"foo"`
}

// TestIssue990 tests that the `in` operator on a struct respects the `expr`
// struct tag the same way plain field access does: once a field is renamed
// via `expr:"..."`, only the tagged name identifies it, and the original Go
// field name is invisible.
func TestIssue990(t *testing.T) {
env := Env{Outer: Outer{Inner: map[string]struct{}{}}}

out, err := expr.Eval(`"bar" in foo`, env)
require.NoError(t, err)
require.Equal(t, true, out)

out, err = expr.Eval(`"Inner" in foo`, env)
require.NoError(t, err)
require.Equal(t, false, out)
}
30 changes: 21 additions & 9 deletions vm/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,25 @@ func findStructField(v reflect.Value, fieldName string) (reflect.Value, reflect.
return reflect.Value{}, reflect.StructField{}, false
}

// structFieldByExprName looks up a field the same way the checker resolves
// `foo.name` at compile time: once a field carries an `expr` tag, only that
// tag's value (or "-" to hide the field) names it, and its original Go name
// no longer applies. This keeps the `in` operator's dynamic string-based
// struct field lookup consistent with static field access.
func structFieldByExprName(t reflect.Type, name string) (reflect.StructField, bool) {
field, ok := t.FieldByNameFunc(func(candidate string) bool {
sf, _ := t.FieldByName(candidate)
if tag := sf.Tag.Get("expr"); tag != "" {
return tag == name
}
return candidate == name
})
if ok && field.IsExported() {
return field, true
}
return reflect.StructField{}, false
}

func fetchFromEmbeddedInterfaces(v reflect.Value, fieldName string) (any, bool) {
t := v.Type()
for i := 0; i < t.NumField(); i++ {
Expand Down Expand Up @@ -286,15 +305,8 @@ func In(needle any, array any) bool {
if !n.IsValid() || n.Kind() != reflect.String {
panic(fmt.Sprintf("cannot use %T as field name of %T", needle, array))
}
field, ok := v.Type().FieldByName(n.String())
if !ok || !field.IsExported() || field.Tag.Get("expr") == "-" {
return false
}
value := v.FieldByIndex(field.Index)
if value.IsValid() {
return true
}
return false
_, ok := structFieldByExprName(v.Type(), n.String())
return ok

case reflect.Ptr:
value := v.Elem()
Expand Down
44 changes: 44 additions & 0 deletions vm/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ type PlainStruct struct {
Title string
}

type TaggedOuter struct {
Inner map[string]struct{} `expr:"bar"`
}

func TestFetchFromEmbeddedInterfaces(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -171,3 +175,43 @@ func TestFetchFromEmbeddedInterfaces(t *testing.T) {
})
}
}

func TestIn_StructRespectsFieldTag(t *testing.T) {
tests := []struct {
name string
input any
needle string
want bool
}{
{
name: "tagged name is found",
input: TaggedOuter{},
needle: "bar",
want: true,
},
{
name: "original Go field name is shadowed by the tag",
input: TaggedOuter{},
needle: "Inner",
want: false,
},
{
name: "field skipped via expr:\"-\" tag is not found",
input: ConcreteWithSkippedField{Title: "hidden"},
needle: "Title",
want: false,
},
{
name: "plain untagged field is found by its Go name",
input: PlainStruct{Title: "hello"},
needle: "Title",
want: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, In(tt.needle, tt.input))
})
}
}