Skip to content

Commit

Permalink
Implement % as partial string matching (#10)
Browse files Browse the repository at this point in the history
  • Loading branch information
majolo authored Mar 22, 2022
1 parent 60da424 commit e8f1db9
Show file tree
Hide file tree
Showing 9 changed files with 102 additions and 33 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ Goven has a simple syntax that allows for powerful queries.

Fields can be compared using the following operators:

`=`, `!=`, `>=`, `<=`, `<`, `>`
`=`, `!=`, `>=`, `<=`, `<`, `>`, `%`

The `%` operator allows you to do partial string matching using LIKE.

Multiple queries can be combined using `AND`, `OR`.

Expand Down
16 changes: 14 additions & 2 deletions example/custom_matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,21 @@ func keyValueMatcher(ex *parser.Expression) (*sql_adaptor.SqlResponse, error) {
if strings.ToLower(slice[1]) != "tags" {
return nil, errors.New("expected tags as field name")
}

// We need to handle the % comparator differently since it isn't implicitly supported in SQL.
defaultMatch := sql_adaptor.DefaultMatcher(&parser.Expression{
Field: "value",
Comparator: ex.Comparator,
Value: ex.Value,
})
rawSnippet := defaultMatch.Raw
if len(defaultMatch.Values) != 1 {
return nil, errors.New("unexpected number of values from matcher")
}
value := defaultMatch.Values[0]
sq := sql_adaptor.SqlResponse{
Raw: fmt.Sprintf("id IN (SELECT model_id FROM %s WHERE key=? AND value%s? AND deleted_at is NULL)", slice[1], ex.Comparator),
Values: []string{slice[2], ex.Value},
Raw: fmt.Sprintf("id IN (SELECT model_id FROM %s WHERE key=? AND %s AND deleted_at is NULL)", slice[1], rawSnippet),
Values: []string{slice[2], value},
}
return &sq, nil
}
73 changes: 52 additions & 21 deletions integration/integration_custom_matcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,45 @@ import (
"github.com/seldonio/goven/example"
)

var (
model1 = &example.Model{
Name: "model1",
Tags: []example.Tag{
example.Tag{
Key: "auto_created",
Value: "true",
},
},
}
model2 = &example.Model{
Name: "model2",
Tags: []example.Tag{
example.Tag{
Key: "auto_created",
Value: "false",
},
},
}
deployment1 = &example.Model{
Name: "deployment1",
Tags: []example.Tag{
example.Tag{
Key: "tag",
Value: "test_partial1",
},
},
}
deployment2 = &example.Model{
Name: "deployment2",
Tags: []example.Tag{
example.Tag{
Key: "tag",
Value: "test_partial2",
},
},
}
)

type testRigModel struct {
pg *embeddedpostgres.EmbeddedPostgres
modelDAO *example.ModelDAO
Expand Down Expand Up @@ -47,28 +86,10 @@ func TestSqlAdaptorModel(t *testing.T) {
defer rig.cleanup()
g.Expect(err).To(BeNil())
// Setup entries
model1Tags := []example.Tag{
example.Tag{
Key: "auto_created",
Value: "true",
},
}
err = rig.modelDAO.CreateModel(&example.Model{
Name: "model1",
Tags: model1Tags,
})
g.Expect(err).To(BeNil())
model2Tags := []example.Tag{
example.Tag{
Key: "auto_created",
Value: "false",
},
for _, model := range []*example.Model{model1, model2, deployment1, deployment2} {
err = rig.modelDAO.CreateModel(model)
g.Expect(err).To(BeNil())
}
err = rig.modelDAO.CreateModel(&example.Model{
Name: "model2",
Tags: model2Tags,
})
g.Expect(err).To(BeNil())
t.Run("test simple successful query", func(t *testing.T) {
result, err := rig.modelDAO.MakeQuery("name=model1")
g.Expect(err).To(BeNil())
Expand All @@ -93,4 +114,14 @@ func TestSqlAdaptorModel(t *testing.T) {
g.Expect(len(result[0].Tags)).To(Equal(1))
g.Expect(result[0].Tags[0].Value).To(Equal("false"))
})
t.Run("test partial string match", func(t *testing.T) {
result, err := rig.modelDAO.MakeQuery(`name%"model"`)
g.Expect(err).To(BeNil())
g.Expect(len(result)).To(Equal(2))
})
t.Run("test partial string tags", func(t *testing.T) {
result, err := rig.modelDAO.MakeQuery(`tags[tag] % partial`)
g.Expect(err).To(BeNil())
g.Expect(len(result)).To(Equal(2))
})
}
6 changes: 4 additions & 2 deletions parser/lexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ func (s *Lexer) Scan() TokenInfo {
return TokenInfo{OPEN_BRACKET, string(ch)}
case ch == ')':
return TokenInfo{CLOSED_BRACKET, string(ch)}
case ch == '%':
return TokenInfo{PERCENT, string(ch)}
case isWhitespace(ch):
s.unread()
return s.scanWhitespace()
Expand Down Expand Up @@ -158,7 +160,7 @@ func (s *Lexer) unread() {
func isWhitespace(ch rune) bool { return ch == ' ' || ch == '\t' || ch == '\n' }

func isSpecialChar(ch rune) bool {
specialChar := []rune{'=', '>', '!', '<', '(', ')'}
specialChar := []rune{'=', '>', '!', '<', '(', ')', '%'}
for _, char := range specialChar {
if ch == char {
return true
Expand All @@ -172,7 +174,7 @@ func isTokenGate(tok Token) bool {
}

func isTokenComparator(tok Token) bool {
return tok == GREATER_THAN || tok == GREATHER_THAN_EQUAL || tok == LESS_THAN || tok == LESS_THAN_EQUAL || tok == EQUAL || tok == NOT_EQUAL
return tok == GREATER_THAN || tok == GREATHER_THAN_EQUAL || tok == LESS_THAN || tok == LESS_THAN_EQUAL || tok == EQUAL || tok == NOT_EQUAL || tok == PERCENT
}

// eof represents a marker rune for the end of the reader.
Expand Down
7 changes: 7 additions & 0 deletions parser/lexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ func TestLexer(t *testing.T) {
g.Expect(tokens).To(Equal([]Token{STRING, EQUAL, STRING, EOF}))
g.Expect(literals).To(Equal([]string{"name", "=", "model1", ""}))
})
t.Run("scan into tokens succeeds percent", func(t *testing.T) {
s := "name%model1"
lexer := NewLexerFromString(s)
tokens, literals := lexerHelper(lexer)
g.Expect(tokens).To(Equal([]Token{STRING, PERCENT, STRING, EOF}))
g.Expect(literals).To(Equal([]string{"name", "%", "model1", ""}))
})
t.Run("scan into tokens succeeds for quoted string", func(t *testing.T) {
s := "name=\"Iris Classifier\""
lexer := NewLexerFromString(s)
Expand Down
4 changes: 2 additions & 2 deletions parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func TestBasicParser(t *testing.T) {
}
})
t.Run("parse operations correctly formatted succeeds", func(t *testing.T) {
test := "name=max AND artifact=art1"
test := "name=max AND artifact%art1"
parser := NewParser(test)
expected := &Operation{
LeftNode: &Expression{
Expand All @@ -61,7 +61,7 @@ func TestBasicParser(t *testing.T) {
Gate: "AND",
RightNode: &Expression{
Field: "artifact",
Comparator: "=",
Comparator: "%",
Value: "art1",
},
}
Expand Down
8 changes: 5 additions & 3 deletions parser/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ type TokenInfo struct {
Literal string
}

// tokenLookup is a map, useful for printing readable names of the tokens.
var tokenLookup = map[Token]string{
// TokenLookup is a map, useful for printing readable names of the tokens.
var TokenLookup = map[Token]string{
OTHER: "OTHER",
EOF: "EOF",
WS: "WS",
Expand All @@ -25,11 +25,12 @@ var tokenLookup = map[Token]string{
OR: "OR",
OPEN_BRACKET: "(",
CLOSED_BRACKET: ")",
PERCENT: "%",
}

// String prints a human readable string name for a given token.
func (t Token) String() (print string) {
return tokenLookup[t]
return TokenLookup[t]
}

// Declare the tokens here.
Expand All @@ -54,6 +55,7 @@ const (
LESS_THAN_EQUAL
EQUAL
NOT_EQUAL
PERCENT

// Keywords
AND
Expand Down
9 changes: 7 additions & 2 deletions sql_adaptor/sql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,18 @@ func TestSqlAdaptor(t *testing.T) {
expectedRaw: "((name=? AND email=?) OR age>?)",
expectedValues: []string{"", "[email protected]", "1"},
},
{
test: "(name%max AND [email protected]) OR age > 1",
expectedRaw: "((name LIKE ? AND email=?) OR age>?)",
expectedValues: []string{"%max%", "[email protected]", "1"},
},
}
for _, testCase := range testCases {
sa := sql_adaptor.NewDefaultAdaptorFromStruct(reflect.ValueOf(&ExampleDBStruct{}))
response, err := sa.Parse(testCase.test)
g.Expect(err).To(BeNil(), fmt.Sprintf("failed case: %s", testCase.test))
g.Expect(response.Raw).To(Equal(testCase.expectedRaw), fmt.Sprintf("failed case: %s", testCase.test))
g.Expect(response.Values).To(Equal(testCase.expectedValues), fmt.Sprintf("failed case: %s", testCase.test))
g.Expect(response.Raw).To(Equal(testCase.expectedRaw), fmt.Sprintf("failed case raw: %s", testCase.test))
g.Expect(response.Values).To(Equal(testCase.expectedValues), fmt.Sprintf("failed case values: %s", testCase.test))
}
})
t.Run("test sql adaptor failure", func(t *testing.T) {
Expand Down
8 changes: 8 additions & 0 deletions sql_adaptor/validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ func DefaultMatcherWithValidator(validate ValidatorFunc) ParseValidateFunc {

// DefaultMatcher takes an expression and spits out the default SqlResponse.
func DefaultMatcher(ex *parser.Expression) *SqlResponse {
if ex.Comparator == parser.TokenLookup[parser.PERCENT] {
fmtValue := fmt.Sprintf("%%%s%%", ex.Value)
sq := SqlResponse{
Raw: fmt.Sprintf("%s LIKE ?", ex.Field),
Values: []string{fmtValue},
}
return &sq
}
sq := SqlResponse{
Raw: fmt.Sprintf("%s%s?", ex.Field, ex.Comparator),
Values: []string{ex.Value},
Expand Down

0 comments on commit e8f1db9

Please sign in to comment.