Skip to content

Commit

Permalink
Merge pull request #2 from danielgtaylor/dates
Browse files Browse the repository at this point in the history
feat: datetime comparisons, fuzz testing
  • Loading branch information
danielgtaylor authored Sep 27, 2022
2 parents bdb4ac5 + f847af1 commit 6c55def
Show file tree
Hide file tree
Showing 20 changed files with 202 additions and 12 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ Non-boolean values are converted to booleans. The following result in `true`:
- Indexing, e.g. `foo[0]`
- Slicing, e.g. `foo[1:2]` or `foo[2:]`
- `.length` pseudo-property, e.g. `foo.length`
- `.lower` pseudo-property for lowercase, e.g. `foo.lower`
- `.upper` pseudo-property for uppercase, e.g. `foo.upper`
- `+` (concatenation)
- `in` e.g. `"f" in "foo"`
- `contains` e.g. `"foo" contains "f"`
Expand All @@ -147,6 +149,13 @@ Any value concatenated with a string will result in a string. For example `"id"

There is no distinction between strings, bytes, or runes. Everything is treated as a string.

#### Date Comparisons

String dates & times can be compared if they follow RFC 3339 / ISO 8601 with or without timezones.

- `before`, e.g. `start before "2020-01-01"`
- `after`, e.g. `created after "2020-01-01T12:00:00Z"`

### Array/slice operators

- Indexing, e.g. `foo[1]`
Expand Down
43 changes: 42 additions & 1 deletion conversions.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package mexpr

import "fmt"
import (
"fmt"
"reflect"
"time"
)

func isNumber(v interface{}) bool {
switch v.(type) {
Expand Down Expand Up @@ -64,6 +68,22 @@ func toString(v interface{}) string {
return fmt.Sprintf("%v", v)
}

// toTime converts a string value into a time.Time if possible, otherwise
// returns a zero time.
func toTime(v interface{}) time.Time {
vStr := toString(v)
if t, err := time.Parse(time.RFC3339, vStr); err == nil {
return t
}
if t, err := time.Parse("2006-01-02T15:04:05", vStr); err == nil {
return t
}
if t, err := time.Parse("2006-01-02", vStr); err == nil {
return t
}
return time.Time{}
}

func isSlice(v interface{}) bool {
if _, ok := v.([]interface{}); ok {
return true
Expand Down Expand Up @@ -144,3 +164,24 @@ func normalize(v interface{}) interface{} {

return v
}

// deepEqual returns whether two values are deeply equal.
func deepEqual(left, right any) bool {
l := normalize(left)
r := normalize(right)

// Optimization for simple types to prevent allocations
switch l.(type) {
case float64:
if f, ok := r.(float64); ok {
return l == f
}
case string:
if s, ok := r.(string); ok {
return l == s
}
}

// Otherwise, just use the built-in deep equality check.
return reflect.DeepEqual(left, right)
}
70 changes: 66 additions & 4 deletions interpreter.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ const (
StrictMode InterpreterOption = iota
)

// checkBounds returns an error if the index is out of bounds.
func checkBounds(ast *Node, input any, idx int) Error {
if v, ok := input.([]any); ok {
if idx < 0 || idx >= len(v) {
return NewError(ast.Offset, ast.Length, "invalid index %d for slice of length %d", int(idx), len(v))
}
}
if v, ok := input.(string); ok {
if idx < 0 || idx >= len(v) {
return NewError(ast.Offset, ast.Length, "invalid index %d for string of length %d", int(idx), len(v))
}
}
return nil
}

// Interpreter executes expression AST programs.
type Interpreter interface {
Run(value any) (any, Error)
Expand Down Expand Up @@ -45,6 +60,10 @@ func (i *interpreter) Run(value any) (any, Error) {
}

func (i *interpreter) run(ast *Node, value any) (any, Error) {
if ast == nil {
return nil, nil
}

switch ast.Type {
case NodeIdentifier:
switch ast.Value.(string) {
Expand Down Expand Up @@ -118,6 +137,12 @@ func (i *interpreter) run(ast *Node, value any) (any, Error) {
if end < 0 {
end += float64(len(left))
}
if err := checkBounds(ast, left, int(start)); err != nil {
return nil, err
}
if err := checkBounds(ast, left, int(end)); err != nil {
return nil, err
}
return left[int(start) : int(end)+1], nil
}
left := toString(resultLeft)
Expand All @@ -127,6 +152,12 @@ func (i *interpreter) run(ast *Node, value any) (any, Error) {
if end < 0 {
end += float64(len(left))
}
if err := checkBounds(ast, left, int(start)); err != nil {
return nil, err
}
if err := checkBounds(ast, left, int(end)); err != nil {
return nil, err
}
return left[int(start) : int(end)+1], nil
}
if isNumber(resultRight) {
Expand All @@ -138,12 +169,18 @@ func (i *interpreter) run(ast *Node, value any) (any, Error) {
if idx < 0 {
idx += float64(len(left))
}
if err := checkBounds(ast, left, int(idx)); err != nil {
return nil, err
}
return left[int(idx)], nil
}
left := toString(resultLeft)
if idx < 0 {
idx += float64(len(left))
}
if err := checkBounds(ast, left, int(idx)); err != nil {
return nil, err
}
return string(left[int(idx)]), nil
}
return nil, NewError(ast.Offset, ast.Length, "array index must be number or slice %v", resultRight)
Expand Down Expand Up @@ -214,6 +251,9 @@ func (i *interpreter) run(ast *Node, value any) (any, Error) {
}
return left / right, nil
case NodeModulus:
if int(right) == 0 {
return nil, NewError(ast.Offset, ast.Length, "cannot divide by zero")
}
return int(left) % int(right), nil
case NodePower:
return math.Pow(left, right), nil
Expand All @@ -230,10 +270,10 @@ func (i *interpreter) run(ast *Node, value any) (any, Error) {
return nil, err
}
if ast.Type == NodeEqual {
return normalize(resultLeft) == normalize(resultRight), nil
return deepEqual(resultLeft, resultRight), nil
}
if ast.Type == NodeNotEqual {
return normalize(resultLeft) != normalize(resultRight), nil
return !deepEqual(resultLeft, resultRight), nil
}

left, err := toNumber(ast.Left, resultLeft)
Expand Down Expand Up @@ -272,6 +312,28 @@ func (i *interpreter) run(ast *Node, value any) (any, Error) {
case NodeOr:
return left || right, nil
}
case NodeBefore, NodeAfter:
resultLeft, err := i.run(ast.Left, value)
if err != nil {
return nil, err
}
leftTime := toTime(resultLeft)
if leftTime.IsZero() {
return nil, NewError(ast.Offset, ast.Length, "unable to convert %v to date or time", resultLeft)
}
resultRight, err := i.run(ast.Right, value)
if err != nil {
return nil, err
}
rightTime := toTime(resultRight)
if rightTime.IsZero() {
return nil, NewError(ast.Offset, ast.Length, "unable to convert %v to date or time", resultRight)
}
if ast.Type == NodeBefore {
return leftTime.Before(rightTime), nil
} else {
return leftTime.After(rightTime), nil
}
case NodeIn, NodeContains, NodeStartsWith, NodeEndsWith:
resultLeft, err := i.run(ast.Left, value)
if err != nil {
Expand All @@ -285,7 +347,7 @@ func (i *interpreter) run(ast *Node, value any) (any, Error) {
case NodeIn:
if a, ok := resultRight.([]any); ok {
for _, item := range a {
if item == resultLeft {
if deepEqual(item, resultLeft) {
return true, nil
}
}
Expand All @@ -307,7 +369,7 @@ func (i *interpreter) run(ast *Node, value any) (any, Error) {
case NodeContains:
if a, ok := resultLeft.([]any); ok {
for _, item := range a {
if item == resultRight {
if deepEqual(item, resultRight) {
return true, nil
}
}
Expand Down
26 changes: 26 additions & 0 deletions interpreter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ func TestInterpreter(t *testing.T) {
{expr: "1 != 2", output: true},
{expr: "x.length == 3", input: `{"x": "abc"}`, output: true},
{expr: `19 % 5 == 4`, output: true},
{expr: `foo == 1`, input: `{"foo": []}`, output: false},
{expr: `foo == 1`, input: `{"foo": {}}`, output: false},
// Boolean comparisons
{expr: "1 < 2 and 1 > 2", output: false},
{expr: "1 < 2 and 2 > 1", output: true},
Expand Down Expand Up @@ -104,6 +106,11 @@ func TestInterpreter(t *testing.T) {
{expr: `"foo" endsWith "f"`, output: false},
{expr: `"foo" endsWith "o"`, output: true},
{expr: `"id1" endsWith 1`, output: true},
// Before / after
{expr: `start before end`, input: `{"start": "2022-01-01T12:00:00Z", "end": "2022-01-01T23:59:59Z"}`, output: true},
{expr: `start before end`, input: `{"start": "2022-01-01T12:00:00", "end": "2022-01-01T23:59:59"}`, output: true},
{expr: `start before end`, input: `{"start": "2022-01-01", "end": "2022-01-02"}`, output: true},
{expr: `start after end`, input: `{"start": "2022-01-01T12:00:00Z", "end": "2022-01-01T23:59:59Z"}`, output: false},
// Length
{expr: `"foo".length`, output: 3},
{expr: `str.length`, input: `{"str": "abcdef"}`, output: 6},
Expand All @@ -126,6 +133,7 @@ func TestInterpreter(t *testing.T) {
{expr: "6 -", err: "incomplete expression"},
{expr: `foo.bar + "baz"`, input: `{"foo": 1}`, err: "no property bar"},
{expr: `foo + 1`, input: `{"foo": [1, 2]}`, err: "cannot operate on incompatible types"},
{expr: `foo > 1`, input: `{"foo": []}`, err: "cannot compare array with number"},
{expr: `foo[1-]`, input: `{"foo": "hello"}`, err: "unexpected right-bracket"},
{expr: `not (1- <= 5)`, err: "missing right operand"},
{expr: `(1 >=)`, err: "unexpected right-paren"},
Expand All @@ -138,6 +146,8 @@ func TestInterpreter(t *testing.T) {
{expr: `0.5 > "some kind of string"`, err: "unable to convert to number"},
{expr: `foo beginswith "bar"`, input: `{"foo": "bar"}`, err: "expected eof"},
{expr: `1 / (foo * 1)`, input: `{"foo": 0}`, err: "cannot divide by zero"},
{expr: `1 before "2020-01-01"`, err: "unable to convert 1 to date or time"},
{expr: `"2020-01-01" after "invalid"`, err: "unable to convert invalid to date or time"},
}

for _, tc := range cases {
Expand Down Expand Up @@ -181,6 +191,22 @@ func TestInterpreter(t *testing.T) {
}
}

func FuzzMexpr(f *testing.F) {
f.Fuzz(func(t *testing.T, s string) {
Eval(s, nil)
Eval(s, map[string]any{
"b": true,
"i": 5,
"f": 1.0,
"s": "Hello",
"a": []any{false, 1, "a"},
"o": map[string]any{
"prop": 123,
},
})
})
}

func Benchmark(b *testing.B) {
benchmarks := []struct {
name string
Expand Down
7 changes: 5 additions & 2 deletions lexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func (t TokenType) String() string {
case TokenNot:
return "not"
case TokenStringCompare:
return "string-compare(in/contains/starts/ends)"
return "string-compare"
case TokenWhere:
return "where"
case TokenEOF:
Expand Down Expand Up @@ -218,7 +218,7 @@ func (l *lexer) consumeIdentifier() *Token {
return l.newToken(TokenOr, value)
case "not":
return l.newToken(TokenNot, value)
case "in", "contains", "startsWith", "endsWith":
case "in", "contains", "startsWith", "endsWith", "before", "after":
return l.newToken(TokenStringCompare, value)
case "where":
return l.newToken(TokenWhere, value)
Expand Down Expand Up @@ -263,6 +263,9 @@ func (l *lexer) Next() (*Token, Error) {
return l.consumeNumber(), nil
}
}
if l.pos-l.lastWidth > uint16(len(l.expression)-1) {
return l.newToken(TokenEOF, ""), nil
}
return l.newToken(b, l.expression[l.pos-l.lastWidth:l.pos]), nil
}

Expand Down
25 changes: 23 additions & 2 deletions parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const (
NodeContains
NodeStartsWith
NodeEndsWith
NodeBefore
NodeAfter
NodeWhere
)

Expand Down Expand Up @@ -99,6 +101,10 @@ func (n Node) String() string {
return "startsWith"
case NodeEndsWith:
return "endsWith"
case NodeBefore:
return "before"
case NodeAfter:
return "after"
case NodeWhere:
return "where"
}
Expand Down Expand Up @@ -161,13 +167,19 @@ func precomputeLiterals(offset uint16, nodeType NodeType, left, right *Node) (*N
case NodeMultiply:
return &Node{Type: NodeLiteral, Offset: offset, Length: l, Value: leftValue * rightValue}, nil
case NodeDivide:
if rightValue == 0 {
return nil, NewError(offset, 1, "cannot divide by zero")
}
return &Node{Type: NodeLiteral, Offset: offset, Length: l, Value: leftValue / rightValue}, nil
case NodeModulus:
if int(rightValue) == 0 {
return nil, NewError(offset, 1, "cannot divide by zero")
}
return &Node{Type: NodeLiteral, Offset: offset, Length: l, Value: float64(int(leftValue) % int(rightValue))}, nil
case NodePower:
return &Node{Type: NodeLiteral, Offset: offset, Length: l, Value: math.Pow(leftValue, rightValue)}, nil
}
return nil, NewError(offset, 1, "Can't precompute unknown operator")
return nil, NewError(offset, 1, "cannot precompute unknown operator")
}

// Parser takes a lexer and parses its tokens into an abstract syntax tree.
Expand Down Expand Up @@ -210,6 +222,9 @@ func (p *parser) parse(bindingPower int) (*Node, Error) {
}
currentToken := *p.token
for bindingPower < bindingPowers[currentToken.Type] {
if leftNode == nil {
return nil, nil
}
if err := p.advance(); err != nil {
return nil, err
}
Expand Down Expand Up @@ -389,6 +404,10 @@ func (p *parser) led(t *Token, n *Node) (*Node, Error) {
nodeType = NodeStartsWith
case "endsWith":
nodeType = NodeEndsWith
case "before":
nodeType = NodeBefore
case "after":
nodeType = NodeAfter
}
return p.newNodeParseRight(n, t, nodeType, bindingPowers[t.Type])
case TokenWhere:
Expand Down Expand Up @@ -416,7 +435,9 @@ func (p *parser) led(t *Token, n *Node) (*Node, Error) {
}

func (p *parser) Parse() (*Node, Error) {
p.advance()
if err := p.advance(); err != nil {
return nil, err
}
n, err := p.parse(0)
return p.ensure(n, err, TokenEOF)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
go test fuzz v1
string("+!")
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
go test fuzz v1
string("a[7]")
Loading

0 comments on commit 6c55def

Please sign in to comment.