From e8f1db95cb8044189114a61523a29c8928cc5927 Mon Sep 17 00:00:00 2001 From: Max Lowther Date: Tue, 22 Mar 2022 11:37:24 +0000 Subject: [PATCH] Implement % as partial string matching (#10) --- README.md | 4 +- example/custom_matcher.go | 16 +++- .../integration_custom_matcher_test.go | 73 +++++++++++++------ parser/lexer.go | 6 +- parser/lexer_test.go | 7 ++ parser/parser_test.go | 4 +- parser/token.go | 8 +- sql_adaptor/sql_test.go | 9 ++- sql_adaptor/validators.go | 8 ++ 9 files changed, 102 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index cbed097..227f243 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/example/custom_matcher.go b/example/custom_matcher.go index b1f02c6..ffacb1f 100644 --- a/example/custom_matcher.go +++ b/example/custom_matcher.go @@ -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 } diff --git a/integration/integration_custom_matcher_test.go b/integration/integration_custom_matcher_test.go index 1fae2b6..abf3bdf 100644 --- a/integration/integration_custom_matcher_test.go +++ b/integration/integration_custom_matcher_test.go @@ -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 @@ -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()) @@ -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)) + }) } diff --git a/parser/lexer.go b/parser/lexer.go index 3ca0457..a8d2a8d 100644 --- a/parser/lexer.go +++ b/parser/lexer.go @@ -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() @@ -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 @@ -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. diff --git a/parser/lexer_test.go b/parser/lexer_test.go index e7fc5bc..45ed5dc 100644 --- a/parser/lexer_test.go +++ b/parser/lexer_test.go @@ -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) diff --git a/parser/parser_test.go b/parser/parser_test.go index 403dc4b..6b23649 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -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{ @@ -61,7 +61,7 @@ func TestBasicParser(t *testing.T) { Gate: "AND", RightNode: &Expression{ Field: "artifact", - Comparator: "=", + Comparator: "%", Value: "art1", }, } diff --git a/parser/token.go b/parser/token.go index 0ad741b..4ce0bf6 100644 --- a/parser/token.go +++ b/parser/token.go @@ -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", @@ -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. @@ -54,6 +55,7 @@ const ( LESS_THAN_EQUAL EQUAL NOT_EQUAL + PERCENT // Keywords AND diff --git a/sql_adaptor/sql_test.go b/sql_adaptor/sql_test.go index 0f1acff..8779368 100644 --- a/sql_adaptor/sql_test.go +++ b/sql_adaptor/sql_test.go @@ -46,13 +46,18 @@ func TestSqlAdaptor(t *testing.T) { expectedRaw: "((name=? AND email=?) OR age>?)", expectedValues: []string{"", "bob-dylan@aol.com", "1"}, }, + { + test: "(name%max AND email=bob-dylan@aol.com) OR age > 1", + expectedRaw: "((name LIKE ? AND email=?) OR age>?)", + expectedValues: []string{"%max%", "bob-dylan@aol.com", "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) { diff --git a/sql_adaptor/validators.go b/sql_adaptor/validators.go index 16c27f5..449dad2 100644 --- a/sql_adaptor/validators.go +++ b/sql_adaptor/validators.go @@ -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},