Skip to content
Merged
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ p, err := expr.Compile(`Subtotal() > 100 && len(Items) >= 2`, expr.WithBuiltins(
v, err := p.Run(ctx, order) // order is some struct with a Subtotal() method
```

Struct tags are opt-in when your expression surface should match a JSON-shaped
API contract:

```go
p, err := expr.Compile(
`result.environment.status == "ready"`,
expr.WithStructTags("json"),
)
```

## JSON-style literals

Object and array literals work the way you'd hope, without the Go ceremony:
Expand Down
114 changes: 114 additions & 0 deletions bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,120 @@ func Benchmark_envStruct(b *testing.B) {
}
}

type taggedPrice struct {
Value int `json:"value"`
}
type taggedPriceEnv struct {
Price taggedPrice `json:"price"`
}

func Benchmark_structTagsNested(b *testing.B) {
env := taggedPriceEnv{Price: taggedPrice{Value: 1}}
cases := []struct {
name string
src string
opts []expr.Option
}{
{name: "go_names", src: `Price.Value > 0`, opts: engOpts},
{name: "json_tags", src: `price.value > 0`, opts: []expr.Option{expr.WithBuiltins(), expr.WithStructTags("json")}},
}

for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
program, err := expr.Compile(tc.src, tc.opts...)
if err != nil {
b.Fatal(err)
}

var out any
ctx := context.Background()
b.ResetTimer()
for n := 0; n < b.N; n++ {
out, err = program.Run(ctx, env)
}
b.StopTimer()

if err != nil {
b.Fatal(err)
}
if !out.(bool) {
b.Fatalf("unexpected result %v", out)
}
})
}
}

type wideTaggedEnv struct {
Field00 int `json:"field_00"`
Field01 int `json:"field_01"`
Field02 int `json:"field_02"`
Field03 int `json:"field_03"`
Field04 int `json:"field_04"`
Field05 int `json:"field_05"`
Field06 int `json:"field_06"`
Field07 int `json:"field_07"`
Field08 int `json:"field_08"`
Field09 int `json:"field_09"`
Field10 int `json:"field_10"`
Field11 int `json:"field_11"`
Field12 int `json:"field_12"`
Field13 int `json:"field_13"`
Field14 int `json:"field_14"`
Field15 int `json:"field_15"`
Field16 int `json:"field_16"`
Field17 int `json:"field_17"`
Field18 int `json:"field_18"`
Field19 int `json:"field_19"`
Field20 int `json:"field_20"`
Field21 int `json:"field_21"`
Field22 int `json:"field_22"`
Field23 int `json:"field_23"`
Field24 int `json:"field_24"`
Field25 int `json:"field_25"`
Field26 int `json:"field_26"`
Field27 int `json:"field_27"`
Field28 int `json:"field_28"`
Field29 int `json:"field_29"`
Field30 int `json:"field_30"`
Field31 int `json:"field_31"`
}

func Benchmark_structTagsWide(b *testing.B) {
env := wideTaggedEnv{Field31: 42}
cases := []struct {
name string
src string
opts []expr.Option
}{
{name: "go_names", src: `Field31 > 0 && Field31 < 99`, opts: engOpts},
{name: "json_tags", src: `field_31 > 0 && field_31 < 99`, opts: []expr.Option{expr.WithBuiltins(), expr.WithStructTags("json")}},
}

for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
program, err := expr.Compile(tc.src, tc.opts...)
if err != nil {
b.Fatal(err)
}

var out any
ctx := context.Background()
b.ResetTimer()
for n := 0; n < b.N; n++ {
out, err = program.Run(ctx, env)
}
b.StopTimer()

if err != nil {
b.Fatal(err)
}
if !out.(bool) {
b.Fatalf("unexpected result %v", out)
}
})
}
}

func Benchmark_envMap(b *testing.B) {
env := map[string]any{
"price": Price{Value: 1},
Expand Down
24 changes: 24 additions & 0 deletions docs/guides/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,30 @@ field, and a `Subtotal() float64` method. Field lookup beats method
lookup: if the struct had both a `Meta` field and a `Meta()` method, the
field wins.

If your struct is the typed form of a JSON-shaped API contract, opt in to
tag lookup and keep the expression in public-schema names:

```go
result.environment.status == "ready"
```

Where `result` is a struct value like:

```go
type Output struct {
Environment Environment `json:"environment"`
}

type Environment struct {
Status string `json:"status"`
}

p, err := expr.Compile(
`result.environment.status == "ready"`,
expr.WithStructTags("json"),
)
```

---

## 7. Role-based access control
Expand Down
40 changes: 38 additions & 2 deletions docs/reference/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,37 @@ Unexported struct fields are **not** reachable by name. Attempting to
select one returns `ErrEvaluate: field ... not found` — we deliberately
do not panic.

Struct tags are ignored by default. Opt in with
`expr.WithStructTags("expr", "json")` (or the alias `WithFieldTags`) to
resolve exported struct fields by tag before Go field names. Tag names
are tried in the order configured, and the first non-empty tag name wins
for that field:

```go
type User struct {
DisplayName string `expr:"name" json:"display_name"`
Email string `json:"email,omitempty"`
SourceID string `json:",omitempty"`
Secret string `expr:"-"`
}

p, _ := expr.Compile(`user.name == "Ada" && user.email != ""`,
expr.WithStructTags("expr", "json"))
```

Tag options after a comma are ignored, so `json:"email,omitempty"`
resolves as `email`. An empty tag name such as `json:",omitempty"`
falls back to the Go exported field name. `expr:"-"` hides the field
entirely when the `expr` tag is configured. Other `"-"` tags only hide
that tag lookup; lower-priority tags or the Go field name may still
expose the field.

Tag lookup uses strict precedence per field. In the example above,
`user.name` resolves to `DisplayName`, while `user.display_name` and
`user.DisplayName` do not. If two exported fields resolve to the same
expression name, field access returns an `ErrEvaluate` ambiguity error
rather than choosing one by declaration order.

## Selectors (`x.y`)

`x.y` evaluates `x`, then looks up `y` on the result:
Expand All @@ -162,7 +193,8 @@ do not panic.
- `map[string]any` or any map with string keys → `y` is a key. Missing
keys return an error (not a zero value).
- Map with non-string keys → `ErrEvaluate`.
- Struct → the exported field `y`, or `ErrEvaluate` if missing.
- Struct → the exported field `y`, using configured struct tags if any,
or `ErrEvaluate` if missing.
- Pointer to struct → dereferenced and re-tried. Nil pointer →
`ErrEvaluate`.
- Anything else → `ErrEvaluate`.
Expand Down Expand Up @@ -208,7 +240,7 @@ Given `x.f()` where `x` evaluates to `recv`:
2. Else, `reflect.Value.MethodByName("f")` on the pointer or original
receiver (so pointer-receiver methods are visible).
3. Else, if the dereferenced kind is `Struct`, the exported field `f`
(as a function value).
(as a function value), using configured struct tags if any.
4. Else, if the dereferenced kind is a `Map` with string keys, the entry
`recv["f"]`.
5. Else: `ErrEvaluate: method ... not found`.
Expand Down Expand Up @@ -266,6 +298,10 @@ Options apply in order, so a later `WithFunctions` wins over an earlier
are baked into the returned `*Program`, so `Run` needs no further
configuration.

`WithStructTags("expr", "json")` / `WithFieldTags(...)` controls struct
field names only. It does not change map key lookup, method names, or
registered function names.

The standard set is:

| Name | Signature | Notes |
Expand Down
19 changes: 19 additions & 0 deletions docs_examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,25 @@ func TestDocsExample6_StructEnv(t *testing.T) {
assertDeepEqual(t, got, true)
}

type docEnvironmentOutput struct {
Environment docEnvironment `json:"environment"`
}

type docEnvironment struct {
Status string `json:"status"`
}

func TestDocsExample6_StructTags(t *testing.T) {
src := `result.environment.status == "ready"`
env := map[string]any{
"result": docEnvironmentOutput{
Environment: docEnvironment{Status: "ready"},
},
}
got := runDocExample(t, src, env, WithStructTags("json"))
assertDeepEqual(t, got, true)
}

// Example 7: Role-based access control.
func TestDocsExample7_RBAC(t *testing.T) {
src := `{
Expand Down
34 changes: 31 additions & 3 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ type Option func(*compileConfig)
// It is consumed during parsing to build the function dispatch tables
// baked into the resulting Program.
type compileConfig struct {
funcs map[string]any
prepared map[string]*preparedFunc
funcs map[string]any
prepared map[string]*preparedFunc
fieldTags *structTagConfig
}

func newCompileConfig() *compileConfig {
Expand Down Expand Up @@ -113,6 +114,27 @@ func WithFunctions(funcs map[string]any) Option {
}
}

// WithStructTags enables struct field lookup by the named struct tags.
// Tags are checked in the order provided before falling back to the Go
// exported field name. Tag options after a comma are ignored, so
// `json:"name,omitempty"` resolves as `name` and `json:",omitempty"`
// falls back to the Go field name.
//
// The option is opt-in; without it, struct fields resolve only by Go field
// name, preserving expr's default behavior. `expr:"-"` hides a field when
// the expr tag is configured; duplicate resolved names return an ambiguity
// error at evaluation time.
func WithStructTags(names ...string) Option {
return func(c *compileConfig) {
c.fieldTags = newStructTagConfig(names)
}
}

// WithFieldTags is an alias for [WithStructTags].
func WithFieldTags(names ...string) Option {
return WithStructTags(names...)
}

// Compile parses an expression once for repeated evaluation. The returned
// Program is immutable and safe for concurrent use. Input longer than
// MaxSourceLength is rejected without calling the parser.
Expand All @@ -134,7 +156,13 @@ func Compile(code string, opts ...Option) (*Program, error) {
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrCompile, err)
}
p := &Program{source: code, root: node, funcs: cfg.funcs, prepared: cfg.prepared}
p := &Program{
source: code,
root: node,
funcs: cfg.funcs,
prepared: cfg.prepared,
fieldTags: cfg.fieldTags,
}
p.compile()
return p, nil
}
Expand Down
24 changes: 24 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ any shared name.
| ------------------------------------- | ---------------------------------------------------------- |
| `expr.WithBuiltins()` | Registers the standard builtin function set (see below) |
| `expr.WithFunctions(map[string]any{})`| Registers arbitrary Go functions as callable identifiers |
| `expr.WithStructTags("json")` | Opts struct field lookup into tag names before Go names |

Builtins are opt-in: if you don't pass `WithBuiltins`, the only callable
names are the higher-order forms (always registered) and anything you
Expand Down Expand Up @@ -169,6 +170,29 @@ p, _ := expr.Compile(`Subtotal() > 100 && len(Items) >= 2`, expr.WithBuiltins())
v, _ := p.Run(ctx, order) // struct or *struct with a Subtotal() method
```

Struct tags are ignored unless configured. Use
`expr.WithStructTags("expr", "json")` or the alias `WithFieldTags` to
resolve exported fields by tags first:

```go
type Output struct {
Environment Environment `json:"environment"`
}
type Environment struct {
Status string `json:"status"`
}

p, _ := expr.Compile(`result.environment.status == "ready"`,
expr.WithStructTags("json"))
```

Tag options after commas are ignored (`json:"email,omitempty"` →
`email`), empty tag names fall back to the Go field name
(`json:",omitempty"`), `expr:"-"` hides a field when `expr` tags are
configured, and duplicate resolved names error as ambiguous. Tag
precedence is strict per field: if `expr:"name"` wins, lower tag aliases
and the Go field name for that field are not also exposed.

## Templates

```go
Expand Down
Loading
Loading