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
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Features:
- Fast, low-allocation parser and runtime
- Many simple expressions are zero-allocation
- Type checking during parsing
- Typed scalar functions and lazy `func()` values
- Simple
- Easy to learn
- Easy to read
Expand Down Expand Up @@ -137,6 +138,8 @@ Math operations between constants are precomputed when possible, so it is effici
- `and`
- `or`

Both `and` and `or` are short-circuited.

```py
1 < 2 and 3 < 4
```
Expand All @@ -148,6 +151,27 @@ Non-boolean values are converted to booleans. The following result in `true`:
- array with at least one item
- map with at least one key/value pair

### Functions

- `identifier(...)`

Functions can be called by providing them in the variables map.

```go
result, err := mexpr.Eval("myFunc(a, b)", map[string]interface{}{
"myFunc": func(a, b int) int { return a + b },
"a": 1,
"b": 2,
})
```

Current limitations:

- only regular, non-variadic functions are supported
- parameter and return types must be `bool`, integer, float, or `string`
- functions must have exactly one return value
- zero-argument scalar functions can also be used as lazy values, e.g. `id + 1`

### String operators

- Indexing, e.g. `foo[0]`
Expand Down Expand Up @@ -221,6 +245,21 @@ not (items where id > 3)
- `in` (has key), e.g. `"key" in foo`
- `contains` e.g. `foo contains "key"`

### Conversions

Any value concatenated with a string will result in a string. For example `"id" + 1` will result in `"id1"`.

The value of a variable can be mapped to a function. This allows the implementor to use functions to retrieve actual values of variables rather than pre-computing values:

```go
result, _ := mexpr.Eval(`id + 1`, map[string]interface{}{
"id": func() int { return 123 },
})
// result is 124
```

In combination with `and`/`or` short-circuiting, this allows lazy evaluation.

#### Map wildcard filtering

A `where` clause can be used as a wildcard key to filter values for all keys in a map. The left side of the clause is the map to be filtered, while the right side is an expression to run on each value of the map. If the right side expression evaluates to true then the value is added to the result slice. For example, given:
Expand Down
62 changes: 62 additions & 0 deletions conversions.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ func isNumber(v interface{}) bool {
return true
case float32, float64:
return true
case func() int, func() int8, func() int16, func() int32, func() int64:
return true
case func() uint, func() uint8, func() uint16, func() uint32, func() uint64:
return true
case func() float32, func() float64:
return true
}
return false
}
Expand Down Expand Up @@ -42,6 +48,30 @@ func toNumber(ast *Node, v interface{}) (float64, Error) {
return float64(n), nil
case float32:
return float64(n), nil
case func() int:
return float64(n()), nil
case func() int8:
return float64(n()), nil
case func() int16:
return float64(n()), nil
case func() int32:
return float64(n()), nil
case func() int64:
return float64(n()), nil
case func() uint:
return float64(n()), nil
case func() uint8:
return float64(n()), nil
case func() uint16:
return float64(n()), nil
case func() uint32:
return float64(n()), nil
case func() uint64:
return float64(n()), nil
case func() float32:
return float64(n()), nil
case func() float64:
return n(), nil
}
return 0, NewError(ast.Offset, ast.Length, "unable to convert to number: %v", v)
}
Expand All @@ -50,6 +80,8 @@ func isString(v interface{}) bool {
switch v.(type) {
case string, rune, byte, []byte:
return true
case func() string:
return true
}
return false
}
Expand All @@ -64,6 +96,8 @@ func toString(v interface{}) string {
return string(s)
case []byte:
return string(s)
case func() string:
return s()
}
return fmt.Sprintf("%v", v)
}
Expand Down Expand Up @@ -162,6 +196,34 @@ func normalize(v interface{}) interface{} {
return float64(n)
case []byte:
return string(n)
case func() int:
return float64(n())
case func() int8:
return float64(n())
case func() int16:
return float64(n())
case func() int32:
return float64(n())
case func() int64:
return float64(n())
case func() uint:
return float64(n())
case func() uint8:
return float64(n())
case func() uint16:
return float64(n())
case func() uint32:
return float64(n())
case func() uint64:
return float64(n())
case func() float32:
return float64(n())
case func() float64:
return n()
case func() string:
return n()
case func() bool:
return n()
}

return v
Expand Down
53 changes: 53 additions & 0 deletions functions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package mexpr

import "reflect"

func schemaForScalarType(t reflect.Type) (*schema, bool) {
switch t.Kind() {
case reflect.Bool:
return schemaBool, true
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return schemaNumber, true
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return schemaNumber, true
case reflect.Float32, reflect.Float64:
return schemaNumber, true
case reflect.String:
return schemaString, true
}
return nil, false
}

func getFunctionSchema(v any) (*schema, bool) {
t := reflect.TypeOf(v)
if t == nil || t.Kind() != reflect.Func || t.IsVariadic() || t.NumOut() != 1 {
return nil, false
}

result, ok := schemaForScalarType(t.Out(0))
if !ok {
return nil, false
}

s := newSchema(typeFunction)
s.result = result
s.parameters = make([]*schema, t.NumIn())
for i := 0; i < t.NumIn(); i++ {
param, ok := schemaForScalarType(t.In(i))
if !ok {
return nil, false
}
s.parameters[i] = param
}

return s, true
}

func resolveLazyValue(v any) (any, bool) {
s, ok := getFunctionSchema(v)
if !ok || len(s.parameters) != 0 {
return nil, false
}

return reflect.ValueOf(v).Call(nil)[0].Interface(), true
}
117 changes: 116 additions & 1 deletion interpreter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mexpr

import (
"math"
"reflect"
"strings"
)

Expand Down Expand Up @@ -91,33 +92,51 @@ func (i *interpreter) run(ast *Node, value any) (any, Error) {

switch ast.Type {
case NodeIdentifier:
if resolved, ok := resolveLazyValue(value); ok {
value = resolved
}
switch ast.Value.(string) {
case "@":
return value, nil
case "length":
// Special pseudo-property to get the value's length.
if s, ok := value.(func() string); ok {
return len(s()), nil
}
if s, ok := value.(string); ok {
return len(s), nil
}
if a, ok := value.([]any); ok {
return len(a), nil
}
case "lower":
if s, ok := value.(func() string); ok {
return strings.ToLower(s()), nil
}
if s, ok := value.(string); ok {
return strings.ToLower(s), nil
}
case "upper":
if s, ok := value.(func() string); ok {
return strings.ToUpper(s()), nil
}
if s, ok := value.(string); ok {
return strings.ToUpper(s), nil
}
}
if m, ok := value.(map[string]any); ok {
if v, ok := m[ast.Value.(string)]; ok {
if resolved, ok := resolveLazyValue(v); ok {
return resolved, nil
}
return v, nil
}
}
if m, ok := value.(map[any]any); ok {
if v, ok := m[ast.Value]; ok {
if resolved, ok := resolveLazyValue(v); ok {
return resolved, nil
}
return v, nil
}
}
Expand Down Expand Up @@ -335,11 +354,21 @@ func (i *interpreter) run(ast *Node, value any) (any, Error) {
if err != nil {
return nil, err
}
left := toBool(resultLeft)
switch ast.Type {
case NodeAnd:
if !left {
return left, nil
}
case NodeOr:
if left {
return left, nil
}
}
resultRight, err := i.run(ast.Right, value)
if err != nil {
return nil, err
}
left := toBool(resultLeft)
right := toBool(resultRight)
switch ast.Type {
case NodeAnd:
Expand Down Expand Up @@ -470,6 +499,92 @@ func (i *interpreter) run(ast *Node, value any) (any, Error) {
}
}
return results, nil
case NodeFunctionCall:
funcName := ast.Left.Value.(string)
var fn any
switch m := value.(type) {
case map[string]any:
fn = m[funcName]
case map[any]any:
fn = m[funcName]
}
if fn == nil {
if i.strict {
return nil, NewError(ast.Offset, ast.Length, "function %s not found", funcName)
}
return nil, nil
}

fnType := reflect.TypeOf(fn)
if fnType == nil || fnType.Kind() != reflect.Func {
return nil, NewError(ast.Offset, ast.Length, "%s is not a function", funcName)
}
if fnType.IsVariadic() || fnType.NumOut() != 1 {
return nil, NewError(ast.Offset, ast.Length, "unsupported function type for %s", funcName)
}

params := ast.Value.([]Node)
if len(params) != fnType.NumIn() {
return nil, NewError(ast.Offset, ast.Length, "function %s expects %d parameter(s), got %d", funcName, fnType.NumIn(), len(params))
}

inputs := make([]reflect.Value, 0, len(params))
for idx, param := range params {
paramValue, err := i.run(&param, value)
if err != nil {
return nil, err
}
input, err := convertFunctionArg(ast, funcName, idx, paramValue, fnType.In(idx))
if err != nil {
return nil, err
}
inputs = append(inputs, input)
}

result := reflect.ValueOf(fn).Call(inputs)[0]
return result.Interface(), nil
}
return nil, nil
}

func convertFunctionArg(ast *Node, funcName string, idx int, value any, target reflect.Type) (reflect.Value, Error) {
switch target.Kind() {
case reflect.Bool:
b, ok := value.(bool)
if !ok {
return reflect.Value{}, NewError(ast.Offset, ast.Length, "function %s parameter %d expects bool", funcName, idx+1)
}
return reflect.ValueOf(b).Convert(target), nil
case reflect.String:
if !isString(value) {
return reflect.Value{}, NewError(ast.Offset, ast.Length, "function %s parameter %d expects string", funcName, idx+1)
}
return reflect.ValueOf(toString(value)).Convert(target), nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n, err := toNumber(ast, value)
if err != nil {
return reflect.Value{}, NewError(ast.Offset, ast.Length, "function %s parameter %d expects number", funcName, idx+1)
}
out := reflect.New(target).Elem()
out.SetInt(int64(n))
return out, nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
n, err := toNumber(ast, value)
if err != nil || n < 0 {
return reflect.Value{}, NewError(ast.Offset, ast.Length, "function %s parameter %d expects number", funcName, idx+1)
}
out := reflect.New(target).Elem()
out.SetUint(uint64(n))
return out, nil
case reflect.Float32, reflect.Float64:
n, err := toNumber(ast, value)
if err != nil {
return reflect.Value{}, NewError(ast.Offset, ast.Length, "function %s parameter %d expects number", funcName, idx+1)
}
out := reflect.New(target).Elem()
out.SetFloat(n)
return out, nil
}

return reflect.Value{}, NewError(ast.Offset, ast.Length, "unsupported function type for %s", funcName)
}
Loading