diff --git a/test/issues/990/issue_test.go b/test/issues/990/issue_test.go new file mode 100644 index 000000000..7c87ab5b5 --- /dev/null +++ b/test/issues/990/issue_test.go @@ -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) +} diff --git a/vm/runtime/runtime.go b/vm/runtime/runtime.go index b5aaa5ed8..532727ebf 100644 --- a/vm/runtime/runtime.go +++ b/vm/runtime/runtime.go @@ -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++ { @@ -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() diff --git a/vm/runtime/runtime_test.go b/vm/runtime/runtime_test.go index 14fbff5d9..64c2955fd 100644 --- a/vm/runtime/runtime_test.go +++ b/vm/runtime/runtime_test.go @@ -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 @@ -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)) + }) + } +}