From c3639d74dbd75f6dec3662d759c9cf1ceba71675 Mon Sep 17 00:00:00 2001 From: Steve Ramage <49958178+steve-r-west@users.noreply.github.com> Date: Fri, 19 May 2023 10:40:31 -0700 Subject: [PATCH] Resolves #8 - Improves API for GORM --- README.md | 213 +++++++- external/epsearchast/v3/aliases.go | 37 ++ external/epsearchast/v3/aliases_test.go | 161 ++++++ external/epsearchast/v3/ast.go | 20 +- external/epsearchast/v3/ast_visitor_test.go | 8 +- external/epsearchast/v3/gorm/gorm.go | 124 ----- .../epsearchast/v3/gorm/gorm_query_builder.go | 114 +++++ .../v3/gorm/gorm_query_builder_test.go | 271 ++++++++++ external/epsearchast/v3/gorm/gorm_test.go | 328 ------------ external/epsearchast/v3/reduce.go | 20 + external/epsearchast/v3/semantic_reduce.go | 46 ++ external/epsearchast/v3/validate.go | 33 ++ external/epsearchast/v3/validate_test.go | 421 +++++++++++++++ external/epsearchast/v3/validating_visitor.go | 53 +- .../epsearchast/v3/validating_visitor_test.go | 481 ------------------ external/epsearchast/v3/visitor_facade.go | 69 --- 16 files changed, 1360 insertions(+), 1039 deletions(-) create mode 100644 external/epsearchast/v3/aliases.go create mode 100644 external/epsearchast/v3/aliases_test.go delete mode 100644 external/epsearchast/v3/gorm/gorm.go create mode 100644 external/epsearchast/v3/gorm/gorm_query_builder.go create mode 100644 external/epsearchast/v3/gorm/gorm_query_builder_test.go delete mode 100644 external/epsearchast/v3/gorm/gorm_test.go create mode 100644 external/epsearchast/v3/reduce.go create mode 100644 external/epsearchast/v3/semantic_reduce.go create mode 100644 external/epsearchast/v3/validate.go create mode 100644 external/epsearchast/v3/validate_test.go delete mode 100644 external/epsearchast/v3/validating_visitor_test.go delete mode 100644 external/epsearchast/v3/visitor_facade.go diff --git a/README.md b/README.md index 8edb308..f6d33a2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,218 @@ ## Introduction -This project is designed to help consume the `EP-Internal-Search-Ast-v*` headers +This project is designed to help consume the `EP-Internal-Search-Ast-v*` headers. In particular, it provides functions for processing these headers in a variety of use cases. +### Retrieving an AST +The `GetAst()` function will convert the JSON header into a struct that can be then be processed by other functions: +```go +package example + +import epsearchast_v3 "github.com/elasticpath/epcc-search-ast-helper/external/epsearchast/v3" + +func Example(headerValue string) (*epsearchast_v3.AstNode, error) { + + ast, err := epsearchast_v3.GetAst(headerValue) + + if err != nil { + return nil, err + } else { + return ast, nil + } + +} + +``` + + +### Aliases + +This package provides a way to support aliases for fields, this will allow a user to specify multiple different names for a field, and still have it validated and converted properly: + +```go +package example + +import epsearchast_v3 "github.com/elasticpath/epcc-search-ast-helper/external/epsearchast/v3" + +func Example(ast *epsearchast_v3.AstNode) error { + + //The ast from the user will be converted into a new one, and if the user specified a payment_status field, the new ast will have it recorded as status. + aliasedAst, err := ApplyAliases(ast, map[string]string{"payment_status": "status"}) + + if err != nil { + return err + } + + DoSomethingElse(aliasedAst) + + return err +} +``` + +### Validation + +This package provides a concise way to validate that the operators and fields specified in the header are permitted: + +```go +package example + +import epsearchast_v3 "github.com/elasticpath/epcc-search-ast-helper/external/epsearchast/v3" + +func Example(ast *epsearchast_v3.AstNode) error { + var err error + // The following is an implementation of all the filter operators for orders https://elasticpath.dev/docs/orders/orders-api/orders-api-overview#filtering + err = epsearchast_v3.ValidateAstFieldAndOperators(ast, map[string][]string { + "status": {"eq"}, + "payment": {"eq"}, + "shipping": {"eq"}, + "name": {"eq", "like"}, + "email": {"eq", "like"}, + "customer_id": {"eq", "like"}, + "account_id": {"eq", "like"}, + "account_member_id": {"eq", "like"}, + "contact.name": {"eq", "like"}, + "contact.email": {"eq", "like"}, + "shipping_postcode": {"eq", "like"}, + "billing_postcode": {"eq", "like"}, + "with_tax": {"gt", "ge", "lt", "le"}, + "without_tax": {"gt", "ge", "lt", "le"}, + "currency": {"eq"}, + "product_id": {"eq"}, + "product_sku": {"eq"}, + "created_at": {"eq", "gt", "ge", "lt", "le"}, + "updated_at": {"eq", "gt", "ge", "lt", "le"}, + }) + + if err != nil { + return err + } + + // You can additionally create aliases which allows for one field to reference another: + // In this case any headers that search for a field of `order_status` will be mapped to `status` and use those rules instead. + err = epsearchast_v3.ValidateAstFieldAndOperatorsWithAliases(ast, map[string][]string {"status": {"eq"}}, map[string]string {"order_status": "status"}) + if err != nil { + return err + } + + // Finally you can also supply validators on fields, which may be necessary in some cases depending on your data model or to improve user experience. + // Validation is provided by the go-playground/validator package https://github.com/go-playground/validator#usage-and-documentation + err = epsearchast_v3.ValidateAstFieldAndOperatorsWithValueValidation(ast, map[string][]string {"status": {"eq"}}, map[string]string {"status": "oneof=incomplete complete processing cancelled"}) + + return err +} +``` + +#### Limitations + +At present, you can only use string validators when validating a field, a simple pull request can be created to fix this issue if you need it. + + +### Generating Queries + +#### GORM/SQL + +The following examples shows how to generate a Gorm query with this library. + +```go +package example + +import epsearchast_v3 "github.com/elasticpath/epcc-search-ast-helper/external/epsearchast/v3" +import epsearchast_v3_gorm "github.com/elasticpath/epcc-search-ast-helper/external/epsearchast/v3/gorm" +import "gorm.io/gorm" + +func Example(ast *epsearchast_v3.AstNode, query *gorm.DB) error { + var err error + + // Not Shown: Validation + + // Create query builder + var qb epsearchast_v3.SemanticReducer[epsearchast_v3_gorm.SubQuery] = epsearchast_v3_gorm.DefaultGormQueryBuilder{} + + + sq, err := epsearchast_v3.SemanticReduceAst(ast, qb) + + if err != nil { + return err + } + + // Don't forget to expand the Args argument with ... + query.Where(sq.Clause, sq.Args...) +} +``` + + +##### Limitations + +1. The GORM builder does not support aliases (easy MR to fix). +2. The GORM builder does not support joins (fixable in theory). +3. There is no way currently to specify the type of a field for SQL, which means everything gets written as a string today (fixable with MR). + +##### Advanced Customization + +In some cases you may want to change the behaviour of the generated SQL, the following example shows how to do that +in this case, we want all eq queries for emails to use the lower case, comparison, and for cart_items field to be numeric. + +```go +package example + +import ( + epsearchast_v3 "github.com/elasticpath/epcc-search-ast-helper/external/epsearchast/v3" + "strconv" +) +import epsearchast_v3_gorm "github.com/elasticpath/epcc-search-ast-helper/external/epsearchast/v3/gorm" +import "gorm.io/gorm" + + +func Example(ast *epsearchast_v3.AstNode, query *gorm.DB) error { + var err error + + // Not Shown: Validation + + // Create query builder + var qb epsearchast_v3.SemanticReducer[epsearchast_v3_gorm.SubQuery] = &CustomQueryBuilder{} + + sq, err := epsearchast_v3.SemanticReduceAst(ast, qb) + + if err != nil { + return err + } + + // Don't forget to expand the Args argument with ... + query.Where(sq.Clause, sq.Args...) +} + +type CustomQueryBuilder struct { + epsearchast_v3_gorm.DefaultGormQueryBuilder +} + +func (l *CustomQueryBuilder) VisitEq(first, second string) (*epsearchast_v3_gorm.SubQuery, error) { + if first == "email" { + return &epsearchast_v3_gorm.SubQuery{ + Clause: fmt.Sprintf("LOWER(%s::text) = LOWER(?)", first), + Args: []interface{}{second}, + }, nil + } else if first == "cart_items" { + n, err := strconv.Atoi(second) + if err != nil { + return nil, err + } + return &epsearchast_v3_gorm.SubQuery{ + Clause: fmt.Sprintf("%s = ?", first), + Args: []interface{}{n}, + }, nil + } else { + return DefaultGormQueryBuilder.VisitEq(l.DefaultGormQueryBuilder, first, second) + } +} +``` + + +### FAQ + +#### Design + +##### Why does validation include alias resolution, why not process aliases first? + +When validation errors occur, those errors go back to the user, so telling the user the error that occurred using the term they specified improves usability. diff --git a/external/epsearchast/v3/aliases.go b/external/epsearchast/v3/aliases.go new file mode 100644 index 0000000..30e1f8d --- /dev/null +++ b/external/epsearchast/v3/aliases.go @@ -0,0 +1,37 @@ +package epsearchast_v3 + +// ApplyAliases will return a new AST where all aliases have been resolved to their new value. +// This function should be called after validating it. +func ApplyAliases(a *AstNode, aliases map[string]string) (*AstNode, error) { + aliasFunc := func(a *AstNode, children []*AstNode) (*AstNode, error) { + + newArgs := make([]string, len(a.Args)) + copy(newArgs, a.Args) + + if len(newArgs) > 0 { + if v, ok := aliases[newArgs[0]]; ok { + newArgs[0] = v + } + } else { + newArgs = nil + } + + // When we unmarshal the JSON AST a node with no children has nil for the field. + // Reduce would get messy if you could pass in a nil. + // if we want to do equality testing in Tests we need to not set empty children. + // Or maybe make it a non pointer type or something. + var childrenNodes []*AstNode = nil + + if len(children) > 0 { + childrenNodes = children + } + + return &AstNode{ + NodeType: a.NodeType, + Children: childrenNodes, + Args: newArgs, + }, nil + } + + return ReduceAst(a, aliasFunc) +} diff --git a/external/epsearchast/v3/aliases_test.go b/external/epsearchast/v3/aliases_test.go new file mode 100644 index 0000000..4255e37 --- /dev/null +++ b/external/epsearchast/v3/aliases_test.go @@ -0,0 +1,161 @@ +package epsearchast_v3 + +import ( + "github.com/stretchr/testify/require" + "testing" +) + +func TestApplyAliasesWithFieldAliasSpecified(t *testing.T) { + // Fixture Setup + // language=JSON + inputAstJson := ` + { + "type": "EQ", + "args": [ "payment_status", "paid"] + }` + + // language=JSON + expectedAstJson := ` + { + "type": "EQ", + "args": [ "status", "paid"] + }` + + inputAstNode, err := GetAst(inputAstJson) + require.NoError(t, err) + + expectedAstNode, err := GetAst(expectedAstJson) + require.NoError(t, err) + + // Execute SUT + aliasedAst, err := ApplyAliases(inputAstNode, map[string]string{"payment_status": "status"}) + + // Verify + require.NoError(t, err) + require.NotNil(t, aliasedAst) + + require.Equal(t, expectedAstNode, aliasedAst) +} + +func TestApplyAliasesReturnsCorrectAstWhenNoAliasesApply(t *testing.T) { + // Fixture Setup + // language=JSON + inputAstJson := ` + { + "type": "EQ", + "args": [ "status", "paid"] + }` + + // language=JSON + expectedAstJson := ` + { + "type": "EQ", + "args": [ "status", "paid"] + }` + + inputAstNode, err := GetAst(inputAstJson) + require.NoError(t, err) + + expectedAstNode, err := GetAst(expectedAstJson) + require.NoError(t, err) + + // Execute SUT + aliasedAst, err := ApplyAliases(inputAstNode, map[string]string{"customer_name": "customer.name"}) + + // Verify + require.NoError(t, err) + require.NotNil(t, aliasedAst) + + require.Equal(t, expectedAstNode, aliasedAst) +} + +func TestApplyAliasesReturnsCorrectAstWhenAliasMapIsEmpty(t *testing.T) { + // Fixture Setup + // language=JSON + inputAstJson := ` + { + "type": "EQ", + "args": [ "status", "paid"] + }` + + // language=JSON + expectedAstJson := ` + { + "type": "EQ", + "args": [ "status", "paid"] + }` + + inputAstNode, err := GetAst(inputAstJson) + require.NoError(t, err) + + expectedAstNode, err := GetAst(expectedAstJson) + require.NoError(t, err) + + // Execute SUT + aliasedAst, err := ApplyAliases(inputAstNode, map[string]string{}) + + // Verify + require.NoError(t, err) + require.NotNil(t, aliasedAst) + + require.Equal(t, expectedAstNode, aliasedAst) +} + +func TestApplyAliasesReturnsCorrectAstWhenAliasTwoFieldsAreAliasedInAnAnd(t *testing.T) { + // Fixture Setup + // language=JSON + inputAstJson := ` + { + "type": "AND", + "children": [ + { + "type": "EQ", + "args": [ "payment_status", "paid"] + }, + { + "type": "LIKE", + "args": [ "customer_name", "Ron*"] + }, + { + "type": "EQ", + "args": [ "customer.email", "ron@swanson.com"] + } + ] + } + ` + + // language=JSON + expectedAstJson := ` + { + "type": "AND", + "children": [ + { + "type": "EQ", + "args": [ "status", "paid"] + }, + { + "type": "LIKE", + "args": [ "customer.name", "Ron*"] + }, + { + "type": "EQ", + "args": [ "customer.email", "ron@swanson.com"] + } + ] + }` + + inputAstNode, err := GetAst(inputAstJson) + require.NoError(t, err) + + expectedAstNode, err := GetAst(expectedAstJson) + require.NoError(t, err) + + // Execute SUT + aliasedAst, err := ApplyAliases(inputAstNode, map[string]string{"payment_status": "status", "customer_name": "customer.name"}) + + // Verify + require.NoError(t, err) + require.NotNil(t, aliasedAst) + + require.Equal(t, expectedAstNode, aliasedAst) +} diff --git a/external/epsearchast/v3/ast.go b/external/epsearchast/v3/ast.go index a23aad4..f09d736 100644 --- a/external/epsearchast/v3/ast.go +++ b/external/epsearchast/v3/ast.go @@ -1,3 +1,4 @@ +// Package epsearchast_v3 implements structs and functions for working with the EP-Internal-Search-AST-v3 header. package epsearchast_v3 import ( @@ -6,12 +7,14 @@ import ( "strings" ) +// An AstNode presents a particular level in the Abstract Syntax Tree. type AstNode struct { - NodeType string `json:"type"` - Children []AstNode `json:"children"` - Args []string `json:"args"` + NodeType string `json:"type"` + Children []*AstNode `json:"children"` + Args []string `json:"args"` } +// GetAst converts the JSON to an AstNode if possible, returning an error otherwise. func GetAst(jsonTxt string) (*AstNode, error) { astNode := &AstNode{} @@ -26,11 +29,17 @@ func GetAst(jsonTxt string) (*AstNode, error) { } } +// The AstVisitor interface provides a way of specifying a [Visitor] for visiting an AST. +// +// This interface is clunky to use for conversions or when you need to return state, and you should use [epsearchast_v3.ReduceAst] instead. +// In particular because the return values are restricted to error, you need to manage and combine the state yourself, which can be more annoying than necessary. +// +// [Visitor]: https://en.wikipedia.org/wiki/Visitor_pattern type AstVisitor interface { PreVisit() error PostVisit() error PreVisitAnd(astNode *AstNode) (bool, error) - PostVisitAnd(astNode *AstNode) (bool, error) + PostVisitAnd(astNode *AstNode) error VisitIn(astNode *AstNode) (bool, error) VisitEq(astNode *AstNode) (bool, error) VisitLe(astNode *AstNode) (bool, error) @@ -40,6 +49,7 @@ type AstVisitor interface { VisitLike(astNode *AstNode) (bool, error) } +// Accept triggers a visit of the AST. func (a *AstNode) Accept(v AstVisitor) error { err := v.PreVisit() @@ -97,7 +107,7 @@ func (a *AstNode) accept(v AstVisitor) error { switch a.NodeType { case "AND": - descend, err = v.PostVisitAnd(a) + err = v.PostVisitAnd(a) if err != nil { return err diff --git a/external/epsearchast/v3/ast_visitor_test.go b/external/epsearchast/v3/ast_visitor_test.go index 7eab9f9..2dd677e 100644 --- a/external/epsearchast/v3/ast_visitor_test.go +++ b/external/epsearchast/v3/ast_visitor_test.go @@ -438,7 +438,7 @@ func TestPreAndPostAndEqAndAndCalledOnAccept(t *testing.T) { On("PostVisit").Return(nil). On("VisitEq", mock.Anything).Return(true, nil). On("PreVisitAnd", mock.Anything).Return(true, nil). - On("PostVisitAnd", mock.Anything).Return(true, nil) + On("PostVisitAnd", mock.Anything).Return(nil) astNode, err := GetAst(jsonTxt) require.NoError(t, err) @@ -535,7 +535,7 @@ func TestPreAndPreVisitAndEqAndPostVisitCalledOnAcceptWithError(t *testing.T) { mockObj.On("PreVisit").Return(nil). On("PreVisitAnd", mock.Anything).Return(true, nil). On("VisitEq", mock.Anything).Return(true, nil). - On("PostVisitAnd", mock.Anything).Return(true, fmt.Errorf("foo")) + On("PostVisitAnd", mock.Anything).Return(fmt.Errorf("foo")) astNode, err := GetAst(jsonTxt) require.NoError(t, err) @@ -566,9 +566,9 @@ func (m *MyMockedVisitor) PreVisitAnd(astNode *AstNode) (bool, error) { return args.Bool(0), args.Error(1) } -func (m *MyMockedVisitor) PostVisitAnd(astNode *AstNode) (bool, error) { +func (m *MyMockedVisitor) PostVisitAnd(astNode *AstNode) error { args := m.Called(astNode) - return args.Bool(0), args.Error(1) + return args.Error(0) } func (m *MyMockedVisitor) VisitIn(astNode *AstNode) (bool, error) { diff --git a/external/epsearchast/v3/gorm/gorm.go b/external/epsearchast/v3/gorm/gorm.go deleted file mode 100644 index 142f3dc..0000000 --- a/external/epsearchast/v3/gorm/gorm.go +++ /dev/null @@ -1,124 +0,0 @@ -package v3_gorm - -import ( - "fmt" - epsearchast_v3 "github.com/elasticpath/epcc-search-ast-helper/external/epsearchast/v3" - "strings" -) - -type GormVisitor struct { - Clause string - Args []interface{} -} - -func NewGormVisitor() *GormVisitor { - return &GormVisitor{} -} - -var _ epsearchast_v3.SearchFilterVisitor = (*GormVisitor)(nil) - -func (g *GormVisitor) PreVisit() error { - g.Clause = " 1=1 " - g.Args = make([]interface{}, 0) - - return nil -} - -func (g *GormVisitor) PostVisit() error { - // Get rid of leading 1=1 - g.Clause = strings.ReplaceAll(g.Clause, " 1=1 AND ", "") - // Remove whitespace from either side - g.Clause = strings.Trim(g.Clause, " ") - // Remove whitespace before closing parenthesis - g.Clause = strings.ReplaceAll(g.Clause, " )", ")") - - // Remove whitespace after opening parenthesis - g.Clause = strings.ReplaceAll(g.Clause, "( ", "(") - return nil -} - -func (g *GormVisitor) PreVisitAnd() error { - g.Clause += "AND ( 1=1 " - return nil -} - -func (g *GormVisitor) PostVisitAnd() error { - g.Clause += ") " - return nil -} - -func (g *GormVisitor) VisitIn(args ...string) error { - s := make([]interface{}, len(args)-1) - for i, v := range args[1:] { - s[i] = v - } - - g.Clause += fmt.Sprintf("AND %s IN ? ", args[0]) - g.Args = append(g.Args, s) - return nil -} - -func (g *GormVisitor) VisitEq(first, second string) error { - g.Clause += fmt.Sprintf("AND LOWER(%s::text) = LOWER(?) ", first) - g.Args = append(g.Args, second) - - return nil -} - -func (g *GormVisitor) VisitLe(first, second string) error { - g.Clause += fmt.Sprintf("AND %s <= ? ", first) - g.Args = append(g.Args, second) - return nil -} - -func (g *GormVisitor) VisitLt(first, second string) error { - g.Clause += fmt.Sprintf("AND %s < ? ", first) - g.Args = append(g.Args, second) - return nil -} - -func (g *GormVisitor) VisitGe(first, second string) error { - g.Clause += fmt.Sprintf("AND %s >= ? ", first) - g.Args = append(g.Args, second) - return nil -} - -func (g *GormVisitor) VisitGt(first, second string) error { - g.Clause += fmt.Sprintf("AND %s > ? ", first) - g.Args = append(g.Args, second) - return nil -} - -func (g *GormVisitor) VisitLike(first, second string) error { - g.Clause += fmt.Sprintf("AND %s ILIKE ? ", first) - g.Args = append(g.Args, processLikeWildcards(second)) - return nil -} - -func processLikeWildcards(valString string) string { - if valString == "*" { - return "%" - } - var startsWithStar = strings.HasPrefix(valString, "*") - var endsWithStar = strings.HasSuffix(valString, "*") - if startsWithStar { - valString = valString[1:] - } - if endsWithStar { - valString = valString[:len(valString)-1] - } - valString = escapeWildcards(valString) - if startsWithStar { - valString = "%" + valString - } - if endsWithStar { - valString += "%" - } - return valString -} - -func escapeWildcards(valString string) string { - valString = strings.ReplaceAll(valString, "%", "\\%") - valString = strings.ReplaceAll(valString, "_", "\\_") - return valString -} diff --git a/external/epsearchast/v3/gorm/gorm_query_builder.go b/external/epsearchast/v3/gorm/gorm_query_builder.go new file mode 100644 index 0000000..db191a8 --- /dev/null +++ b/external/epsearchast/v3/gorm/gorm_query_builder.go @@ -0,0 +1,114 @@ +package epsearchast_v3_gorm + +import ( + "fmt" + epsearchast_v3 "github.com/elasticpath/epcc-search-ast-helper/external/epsearchast/v3" + "strings" +) + +type SubQuery struct { + // The clause that can be passed to Where + Clause string + // An array that should be passed in using the ... operator to Where + Args []interface{} +} + +type DefaultGormQueryBuilder struct{} + +var _ epsearchast_v3.SemanticReducer[SubQuery] = (*DefaultGormQueryBuilder)(nil) + +func (g DefaultGormQueryBuilder) PostVisitAnd(sqs []*SubQuery) (*SubQuery, error) { + clauses := make([]string, 0, len(sqs)) + args := make([]interface{}, 0) + for _, sq := range sqs { + clauses = append(clauses, sq.Clause) + args = append(args, sq.Args...) + } + + return &SubQuery{ + Clause: "( " + strings.Join(clauses, " AND ") + " )", + Args: args, + }, nil +} + +func (g DefaultGormQueryBuilder) VisitIn(args ...string) (*SubQuery, error) { + s := make([]interface{}, len(args)-1) + for i, v := range args[1:] { + s[i] = v + } + + return &SubQuery{ + Clause: fmt.Sprintf("%s IN ?", args[0]), + Args: []interface{}{s}, + }, nil +} + +func (g DefaultGormQueryBuilder) VisitEq(first, second string) (*SubQuery, error) { + return &SubQuery{ + Clause: fmt.Sprintf("%s = ?", first), + Args: []interface{}{second}, + }, nil +} + +func (g DefaultGormQueryBuilder) VisitLe(first, second string) (*SubQuery, error) { + return &SubQuery{ + Clause: fmt.Sprintf("%s <= ?", first), + Args: []interface{}{second}, + }, nil +} + +func (g DefaultGormQueryBuilder) VisitLt(first, second string) (*SubQuery, error) { + return &SubQuery{ + Clause: fmt.Sprintf("%s < ?", first), + Args: []interface{}{second}, + }, nil +} + +func (g DefaultGormQueryBuilder) VisitGe(first, second string) (*SubQuery, error) { + return &SubQuery{ + Clause: fmt.Sprintf("%s >= ?", first), + Args: []interface{}{second}, + }, nil +} + +func (g DefaultGormQueryBuilder) VisitGt(first, second string) (*SubQuery, error) { + return &SubQuery{ + Clause: fmt.Sprintf("%s > ?", first), + Args: []interface{}{second}, + }, nil +} + +func (g DefaultGormQueryBuilder) VisitLike(first, second string) (*SubQuery, error) { + return &SubQuery{ + Clause: fmt.Sprintf("%s LIKE ?", first), + Args: []interface{}{g.ProcessLikeWildcards(second)}, + }, nil +} + +func (g DefaultGormQueryBuilder) ProcessLikeWildcards(valString string) string { + if valString == "*" { + return "%" + } + var startsWithStar = strings.HasPrefix(valString, "*") + var endsWithStar = strings.HasSuffix(valString, "*") + if startsWithStar { + valString = valString[1:] + } + if endsWithStar { + valString = valString[:len(valString)-1] + } + valString = g.EscapeWildcards(valString) + if startsWithStar { + valString = "%" + valString + } + if endsWithStar { + valString += "%" + } + return valString +} + +func (g DefaultGormQueryBuilder) EscapeWildcards(valString string) string { + valString = strings.ReplaceAll(valString, "%", "\\%") + valString = strings.ReplaceAll(valString, "_", "\\_") + return valString +} diff --git a/external/epsearchast/v3/gorm/gorm_query_builder_test.go b/external/epsearchast/v3/gorm/gorm_query_builder_test.go new file mode 100644 index 0000000..bbef4d4 --- /dev/null +++ b/external/epsearchast/v3/gorm/gorm_query_builder_test.go @@ -0,0 +1,271 @@ +package epsearchast_v3_gorm + +import ( + "encoding/json" + "fmt" + epsearchast_v3 "github.com/elasticpath/epcc-search-ast-helper/external/epsearchast/v3" + "github.com/stretchr/testify/require" + "strconv" + "testing" +) + +var binOps = []testOp{ + {"LE", "<="}, + {"LT", "<"}, + {"EQ", "="}, + {"GT", ">"}, + {"GE", ">="}, + {"LIKE", "LIKE"}, +} + +var varOps = []testOp{ + {"IN", "IN"}, +} + +type testOp struct { + AstOp string + SqlOp string +} + +func TestSimpleBinaryOperatorFiltersGeneratesCorrectWhereClause(t *testing.T) { + for _, binOp := range binOps { + t.Run(fmt.Sprintf("%s", binOp.AstOp), func(t *testing.T) { + //Fixture Setup + //language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "%s", + "args": [ "amount", "5"] + }`, binOp.AstOp) + + astNode, err := epsearchast_v3.GetAst(jsonTxt) + + err = json.Unmarshal([]byte(jsonTxt), astNode) + require.NoError(t, err) + + var sr epsearchast_v3.SemanticReducer[SubQuery] = DefaultGormQueryBuilder{} + + // Execute SUT + query, err := epsearchast_v3.SemanticReduceAst(astNode, sr) + + // Verification + + require.NoError(t, err) + + require.Equal(t, fmt.Sprintf("amount %s ?", binOp.SqlOp), query.Clause) + require.Equal(t, []interface{}{"5"}, query.Args) + }) + } + +} + +func TestSimpleVariableOperatorFiltersGeneratesCorrectWhereClause(t *testing.T) { + for _, varOp := range varOps { + t.Run(fmt.Sprintf("%s", varOp.AstOp), func(t *testing.T) { + //Fixture Setup + //language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "%s", + "args": ["amount", "5", "6", "7"] + }`, varOp.AstOp) + + astNode, err := epsearchast_v3.GetAst(jsonTxt) + + err = json.Unmarshal([]byte(jsonTxt), astNode) + require.NoError(t, err) + + var sr epsearchast_v3.SemanticReducer[SubQuery] = DefaultGormQueryBuilder{} + + // Execute SUT + query, err := epsearchast_v3.SemanticReduceAst(astNode, sr) + + // Verification + + require.NoError(t, err) + + require.Equal(t, fmt.Sprintf("amount %s ?", varOp.SqlOp), query.Clause) + require.Equal(t, []interface{}{[]interface{}{"5", "6", "7"}}, query.Args) + }) + } +} + +func TestLikeFilterWildCards(t *testing.T) { + genTest := func(astLiteral string, sqlLiteral string) func(t *testing.T) { + return func(t *testing.T) { + //Fixture Setup + //language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "LIKE", + "args": [ "email", "%s"] + }`, astLiteral) + + astNode, err := epsearchast_v3.GetAst(jsonTxt) + + err = json.Unmarshal([]byte(jsonTxt), astNode) + require.NoError(t, err) + + var sr epsearchast_v3.SemanticReducer[SubQuery] = DefaultGormQueryBuilder{} + + // Execute SUT + query, err := epsearchast_v3.SemanticReduceAst(astNode, sr) + + // Verification + + require.NoError(t, err) + + require.Equal(t, fmt.Sprintf("email LIKE ?"), query.Clause) + require.Equal(t, []interface{}{sqlLiteral}, query.Args) + } + } + + t.Run("Wildcard Only", genTest("*", "%")) + t.Run("Wildcard Prefix", genTest("*s", "%s")) + t.Run("Wildcard Suffix", genTest("s*", "s%")) + t.Run("Wildcard Prefix & Suffix", genTest("*s*", "%s%")) + t.Run("No Wildcards", genTest("s", "s")) +} +func TestSimpleRecursiveStructure(t *testing.T) { + //Fixture Setup + //language=JSON + jsonTxt := ` + { + "type": "AND", + "children": [ + { + "type": "IN", + "args": ["status", "new", "paid"] + }, + { + "type": "GE", + "args": [ "amount", "5"] + } + ] + } + ` + + astNode, err := epsearchast_v3.GetAst(jsonTxt) + + err = json.Unmarshal([]byte(jsonTxt), astNode) + require.NoError(t, err) + + var sr epsearchast_v3.SemanticReducer[SubQuery] = DefaultGormQueryBuilder{} + + // Execute SUT + query, err := epsearchast_v3.SemanticReduceAst(astNode, sr) + + // Verification + + require.NoError(t, err) + + require.Equal(t, "( status IN ? AND amount >= ? )", query.Clause) + require.Equal(t, []interface{}{[]interface{}{"new", "paid"}, "5"}, query.Args) +} + +func TestSimpleRecursiveWithStringOverrideStruct(t *testing.T) { + //Fixture Setup + //language=JSON + jsonTxt := ` + { + "type": "AND", + "children": [ + { + "type": "IN", + "args": ["status", "new", "paid"] + }, + { + "type": "EQ", + "args": [ "email", "ron@swanson.com"] + } + ] + } + ` + + astNode, err := epsearchast_v3.GetAst(jsonTxt) + + err = json.Unmarshal([]byte(jsonTxt), astNode) + require.NoError(t, err) + + var sr epsearchast_v3.SemanticReducer[SubQuery] = &LowerCaseEmail{} + + // Execute SUT + query, err := epsearchast_v3.SemanticReduceAst(astNode, sr) + + // Verification + + require.NoError(t, err) + + require.Equal(t, "( status IN ? AND LOWER(email::text) = LOWER(?) )", query.Clause) +} + +func TestSimpleRecursiveWithIntFieldStruct(t *testing.T) { + //Fixture Setup + //language=JSON + jsonTxt := ` + { + "type": "AND", + "children": [ + { + "type": "IN", + "args": ["status", "new", "paid"] + }, + { + "type": "EQ", + "args": [ "amount", "5"] + } + ] + } + ` + + astNode, err := epsearchast_v3.GetAst(jsonTxt) + + err = json.Unmarshal([]byte(jsonTxt), astNode) + require.NoError(t, err) + + var sr epsearchast_v3.SemanticReducer[SubQuery] = &IntFieldQueryBuilder{} + + // Execute SUT + query, err := epsearchast_v3.SemanticReduceAst(astNode, sr) + + // Verification + + require.NoError(t, err) + + require.Equal(t, "( status IN ? AND amount = ? )", query.Clause) + require.Equal(t, []interface{}{[]interface{}{"new", "paid"}, 5}, query.Args) +} + +type LowerCaseEmail struct { + DefaultGormQueryBuilder +} + +func (l *LowerCaseEmail) VisitEq(first, second string) (*SubQuery, error) { + if first == "email" { + return &SubQuery{ + Clause: fmt.Sprintf("LOWER(%s::text) = LOWER(?)", first), + Args: []interface{}{second}, + }, nil + } else { + return DefaultGormQueryBuilder.VisitEq(l.DefaultGormQueryBuilder, first, second) + } +} + +type IntFieldQueryBuilder struct { + DefaultGormQueryBuilder +} + +func (i *IntFieldQueryBuilder) VisitEq(first, second string) (*SubQuery, error) { + if first == "amount" { + n, err := strconv.Atoi(second) + if err != nil { + return nil, err + } + return &SubQuery{ + Clause: fmt.Sprintf("%s = ?", first), + Args: []interface{}{n}, + }, nil + } else { + return DefaultGormQueryBuilder.VisitEq(i.DefaultGormQueryBuilder, first, second) + } +} diff --git a/external/epsearchast/v3/gorm/gorm_test.go b/external/epsearchast/v3/gorm/gorm_test.go deleted file mode 100644 index a2736e1..0000000 --- a/external/epsearchast/v3/gorm/gorm_test.go +++ /dev/null @@ -1,328 +0,0 @@ -package v3_gorm - -import ( - epsearchast_v3 "github.com/elasticpath/epcc-search-ast-helper/external/epsearchast/v3" - "github.com/stretchr/testify/require" - "testing" -) - -func TestSimpleEqFilterGeneratesCorrectWhereClause(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := ` - { - "type": "EQ", - "args": [ "amount", "5"] - } - ` - - astNode, err := epsearchast_v3.GetAst(jsonTxt) - require.NoError(t, err) - - gormVisitor := NewGormVisitor() - visitor := epsearchast_v3.NewSearchFilterVisitorAdapter(gormVisitor) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.NoError(t, err) - - require.Equal(t, "LOWER(amount::text) = LOWER(?)", gormVisitor.Clause) - require.Equal(t, gormVisitor.Args, []interface{}{"5"}) -} - -func TestSimpleLeFilterGeneratesCorrectWhereClause(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := ` - { - "type": "LE", - "args": [ "amount", "5"] - } - ` - - astNode, err := epsearchast_v3.GetAst(jsonTxt) - require.NoError(t, err) - - gormVisitor := NewGormVisitor() - visitor := epsearchast_v3.NewSearchFilterVisitorAdapter(gormVisitor) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.NoError(t, err) - - require.Equal(t, "amount <= ?", gormVisitor.Clause) - require.Equal(t, gormVisitor.Args, []interface{}{"5"}) -} - -func TestSimpleLtFilterGeneratesCorrectWhereClause(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := ` - { - "type": "LT", - "args": [ "amount", "5"] - } - ` - - astNode, err := epsearchast_v3.GetAst(jsonTxt) - require.NoError(t, err) - - gormVisitor := NewGormVisitor() - visitor := epsearchast_v3.NewSearchFilterVisitorAdapter(gormVisitor) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.NoError(t, err) - - require.Equal(t, "amount < ?", gormVisitor.Clause) - require.Equal(t, gormVisitor.Args, []interface{}{"5"}) -} - -func TestSimpleGeFilterGeneratesCorrectWhereClause(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := ` - { - "type": "GE", - "args": [ "amount", "5"] - } - ` - - astNode, err := epsearchast_v3.GetAst(jsonTxt) - require.NoError(t, err) - - gormVisitor := NewGormVisitor() - visitor := epsearchast_v3.NewSearchFilterVisitorAdapter(gormVisitor) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.NoError(t, err) - - require.Equal(t, "amount >= ?", gormVisitor.Clause) - require.Equal(t, gormVisitor.Args, []interface{}{"5"}) -} - -func TestSimpleGtFilterGeneratesCorrectWhereClause(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := ` - { - "type": "GT", - "args": [ "amount", "5"] - } - ` - - astNode, err := epsearchast_v3.GetAst(jsonTxt) - require.NoError(t, err) - - gormVisitor := NewGormVisitor() - visitor := epsearchast_v3.NewSearchFilterVisitorAdapter(gormVisitor) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.NoError(t, err) - - require.Equal(t, "amount > ?", gormVisitor.Clause) - require.Equal(t, gormVisitor.Args, []interface{}{"5"}) -} - -func TestSimpleInFilterGeneratesCorrectWhereClause(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := ` - { - "type": "IN", - "args": ["status", "new", "paid"] - } - ` - - astNode, err := epsearchast_v3.GetAst(jsonTxt) - require.NoError(t, err) - - gormVisitor := NewGormVisitor() - visitor := epsearchast_v3.NewSearchFilterVisitorAdapter(gormVisitor) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.NoError(t, err) - - require.Equal(t, "status IN ?", gormVisitor.Clause) - require.Equal(t, gormVisitor.Args, []interface{}{[]interface{}{"new", "paid"}}) -} - -func TestSimpleLikeFilterGeneratesCorrectWhereClause(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := ` - { - "type": "LIKE", - "args": [ "amount", "5"] - } - ` - - astNode, err := epsearchast_v3.GetAst(jsonTxt) - require.NoError(t, err) - - gormVisitor := NewGormVisitor() - visitor := epsearchast_v3.NewSearchFilterVisitorAdapter(gormVisitor) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.NoError(t, err) - - require.Equal(t, "amount ILIKE ?", gormVisitor.Clause) - require.Equal(t, gormVisitor.Args, []interface{}{"5"}) -} - -func TestSimpleLikeFilterWithWildcardAtStartGeneratesCorrectWhereClause(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := ` - { - "type": "LIKE", - "args": [ "amount", "*5"] - } - ` - - astNode, err := epsearchast_v3.GetAst(jsonTxt) - require.NoError(t, err) - - gormVisitor := NewGormVisitor() - visitor := epsearchast_v3.NewSearchFilterVisitorAdapter(gormVisitor) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.NoError(t, err) - - require.Equal(t, "amount ILIKE ?", gormVisitor.Clause) - require.Equal(t, gormVisitor.Args, []interface{}{"%5"}) -} - -func TestSimpleLikeFilterWithWildcardAtEndGeneratesCorrectWhereClause(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := ` - { - "type": "LIKE", - "args": [ "amount", "5*"] - } - ` - - astNode, err := epsearchast_v3.GetAst(jsonTxt) - require.NoError(t, err) - - gormVisitor := NewGormVisitor() - visitor := epsearchast_v3.NewSearchFilterVisitorAdapter(gormVisitor) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.NoError(t, err) - - require.Equal(t, "amount ILIKE ?", gormVisitor.Clause) - require.Equal(t, gormVisitor.Args, []interface{}{"5%"}) -} - -func TestSimpleLikeFilterWithWildcardAtBothEndsGeneratesCorrectWhereClause(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := ` - { - "type": "LIKE", - "args": [ "amount", "*5*"] - } - ` - - astNode, err := epsearchast_v3.GetAst(jsonTxt) - require.NoError(t, err) - - gormVisitor := NewGormVisitor() - visitor := epsearchast_v3.NewSearchFilterVisitorAdapter(gormVisitor) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.NoError(t, err) - - require.Equal(t, "amount ILIKE ?", gormVisitor.Clause) - require.Equal(t, gormVisitor.Args, []interface{}{"%5%"}) -} - -func TestSimpleLikeFilterWithWildcardOnlyCorrectWhereClause(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := ` - { - "type": "LIKE", - "args": [ "amount", "*"] - } - ` - - astNode, err := epsearchast_v3.GetAst(jsonTxt) - require.NoError(t, err) - - gormVisitor := NewGormVisitor() - visitor := epsearchast_v3.NewSearchFilterVisitorAdapter(gormVisitor) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.NoError(t, err) - - require.Equal(t, "amount ILIKE ?", gormVisitor.Clause) - require.Equal(t, gormVisitor.Args, []interface{}{"%"}) -} - -func TestSimpleAndFilterGeneratesCorrectWhereClause(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := ` - { - "type": "AND", - "children": [ - { - "type": "IN", - "args": ["status", "new", "paid"] - }, - { - "type": "GE", - "args": [ "amount", "5"] - } - ] - } - ` - - astNode, err := epsearchast_v3.GetAst(jsonTxt) - require.NoError(t, err) - - gormVisitor := NewGormVisitor() - visitor := epsearchast_v3.NewSearchFilterVisitorAdapter(gormVisitor) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.NoError(t, err) - - require.Equal(t, "(status IN ? AND amount >= ?)", gormVisitor.Clause) - require.Equal(t, gormVisitor.Args, []interface{}{[]interface{}{"new", "paid"}, "5"}) -} diff --git a/external/epsearchast/v3/reduce.go b/external/epsearchast/v3/reduce.go new file mode 100644 index 0000000..46286d5 --- /dev/null +++ b/external/epsearchast/v3/reduce.go @@ -0,0 +1,20 @@ +package epsearchast_v3 + +// ReduceAst is a generic function that can be used to compute or build "something" about an AST. +// +// This function recursively calls the supplied f on each node of the tree, passing in the return value of all +// child nodes as an argument. +// +// Depending on what you are doing you may find that [epsearchast_v3.SemanticReduceAst] to be simpler. +func ReduceAst[T any](a *AstNode, f func(*AstNode, []*T) (*T, error)) (*T, error) { + collector := make([]*T, 0, len(a.Children)) + for _, n := range a.Children { + v, err := ReduceAst(n, f) + if err != nil { + return nil, err + } + collector = append(collector, v) + } + + return f(a, collector) +} diff --git a/external/epsearchast/v3/semantic_reduce.go b/external/epsearchast/v3/semantic_reduce.go new file mode 100644 index 0000000..9c1f561 --- /dev/null +++ b/external/epsearchast/v3/semantic_reduce.go @@ -0,0 +1,46 @@ +package epsearchast_v3 + +import "fmt" + +// A SemanticReducer is essentially collection of functions that make it easier to reduce things that working with [epsearchast_v3.AstNode]'s directly. +// +// It provides an individual method for each allowed keyword in the AST, which can make some transforms easier. In particular +// only conjunction operators are required to handle the child arguments, and most other types have there arguments passed in the right type. +type SemanticReducer[R any] interface { + PostVisitAnd([]*R) (*R, error) + VisitIn(args ...string) (*R, error) + VisitEq(first, second string) (*R, error) + VisitLe(first, second string) (*R, error) + VisitLt(first, second string) (*R, error) + VisitGe(first, second string) (*R, error) + VisitGt(first, second string) (*R, error) + VisitLike(first, second string) (*R, error) +} + +// SemanticReduceAst adapts an epsearchast_v3.SemanticReducer for use with the epsearchast_v3.ReduceAst function. +func SemanticReduceAst[T any](a *AstNode, v SemanticReducer[T]) (*T, error) { + f := func(a *AstNode, t []*T) (*T, error) { + switch a.NodeType { + case "LT": + return v.VisitLt(a.Args[0], a.Args[1]) + case "LE": + return v.VisitLe(a.Args[0], a.Args[1]) + case "EQ": + return v.VisitEq(a.Args[0], a.Args[1]) + case "GE": + return v.VisitGe(a.Args[0], a.Args[1]) + case "GT": + return v.VisitGt(a.Args[0], a.Args[1]) + case "LIKE": + return v.VisitLike(a.Args[0], a.Args[1]) + case "IN": + return v.VisitIn(a.Args...) + case "AND": + return v.PostVisitAnd(t) + default: + return nil, fmt.Errorf("unsupported node type: %s", a.NodeType) + } + } + + return ReduceAst(a, f) +} diff --git a/external/epsearchast/v3/validate.go b/external/epsearchast/v3/validate.go new file mode 100644 index 0000000..2e33a3e --- /dev/null +++ b/external/epsearchast/v3/validate.go @@ -0,0 +1,33 @@ +package epsearchast_v3 + +// ValidateAstFieldAndOperators determines whether each field is using the allowed operators, a non-nil error is returned if and only if there is a problem. +// Validation of allowed fields is important because failing to do so could allow queries that are not performant against indexes. +func ValidateAstFieldAndOperators(astNode *AstNode, allowedOps map[string][]string) error { + return ValidateAstFieldAndOperatorsWithAliasesAndValueValidation(astNode, allowedOps, map[string]string{}, map[string]string{}) +} + +// ValidateAstFieldAndOperatorsWithAliases determines whether each field is using the allowed operators, a non-nil error is returned if and only if there is a problem. +// This version of the function unlike [ValidateAstFieldAndOperators] supports aliased names for fields, which enables the user to specify the same field in different ways, if say a column/field is renamed in the DB. +// Validation of allowed fields is important because failing to do so could allow queries that are not performant against indexes. +func ValidateAstFieldAndOperatorsWithAliases(astNode *AstNode, allowedOps map[string][]string, aliases map[string]string) error { + return ValidateAstFieldAndOperatorsWithAliasesAndValueValidation(astNode, allowedOps, aliases, map[string]string{}) +} + +// ValidateAstFieldAndOperatorsWithAliasesAndValueValidation determines whether each field is using the allowed operators, a non-nil error is returned if and only if there is a problem. +// This version of the function unlike [ValidateAstFieldAndOperators] supports validating individual values against a validation rule which can be important in some cases (e.g., if a column/field is an integer in the DB, and string values should be prohibited). +func ValidateAstFieldAndOperatorsWithValueValidation(astNode *AstNode, allowedOps map[string][]string, valueValidators map[string]string) error { + return ValidateAstFieldAndOperatorsWithAliasesAndValueValidation(astNode, allowedOps, map[string]string{}, valueValidators) +} + +// ValidateAstFieldAndOperatorsWithAliasesAndValueValidation determines whether each field is using the allowed operators, a non-nil error is returned if and only if there is a problem. +// This version of the function unlike [ValidateAstFieldAndOperators] supports aliased names for fields which enables the user to specify the same field in different ways, if say a column is renamed in the DB. Validation of allowed fields is important because failing to do so could allow queries that are not performant against indexes. +// This version of the function unlike [ValidateAstFieldAndOperatorsWithAliases] also supports validating individual values against a validation rule which can be important in some cases (e.g., if a column/field is an integer in the DB, and string values should be prohibited). +func ValidateAstFieldAndOperatorsWithAliasesAndValueValidation(astNode *AstNode, allowedOps map[string][]string, aliases map[string]string, valueValidators map[string]string) error { + visitor, err := NewValidatingVisitor(allowedOps, aliases, valueValidators) + + if err != nil { + return err + } + + return astNode.Accept(visitor) +} diff --git a/external/epsearchast/v3/validate_test.go b/external/epsearchast/v3/validate_test.go new file mode 100644 index 0000000..00ca891 --- /dev/null +++ b/external/epsearchast/v3/validate_test.go @@ -0,0 +1,421 @@ +package epsearchast_v3 + +import ( + "fmt" + "github.com/stretchr/testify/require" + "strings" + "testing" +) + +var binOps = []string{"le", "lt", "eq", "ge", "gt", "like"} + +var varOps = []string{"in"} + +func TestValidationReturnsErrorForBinaryOperatorsWhenAstUsesInvalidOperatorForKnownField(t *testing.T) { + for idx, binOp := range binOps { + t.Run(fmt.Sprintf("%s", binOp), func(t *testing.T) { + // Fixture Setup + // language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "%s", + "args": [ "amount", "5"] + } + `, strings.ToUpper(binOp)) + + ast, err := GetAst(jsonTxt) + require.NoError(t, err) + + otherBinOp := binOps[(idx+1)%len(binOps)] + + // Execute SUT + err = ValidateAstFieldAndOperators(ast, map[string][]string{"amount": {otherBinOp}}) + + // Verification + require.ErrorContains(t, err, fmt.Sprintf("unknown operator [%s] specified in search filter for field [amount], allowed operators are [%s]", binOp, otherBinOp)) + }) + } + +} + +func TestValidationReturnsErrorForBinaryOperatorsWhenAstUsesUnknownField(t *testing.T) { + for idx, binOp := range binOps { + t.Run(fmt.Sprintf("%s", binOp), func(t *testing.T) { + // Fixture Setup + // language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "%s", + "args": [ "amount", "5"] + } + `, strings.ToUpper(binOp)) + + ast, err := GetAst(jsonTxt) + require.NoError(t, err) + + otherBinOp := binOps[(idx+1)%len(binOps)] + + // Execute SUT + err = ValidateAstFieldAndOperators(ast, map[string][]string{"other_field": {otherBinOp}}) + + // Verification + require.ErrorContains(t, err, fmt.Sprintf("unknown field [amount] specified in search filter, allowed fields are [other_field]")) + }) + } + +} + +func TestValidationReturnsNoErrorForBinaryOperatorsWhenAstSatisfiesConstraints(t *testing.T) { + + for _, binOp := range binOps { + t.Run(fmt.Sprintf("%s", binOp), func(t *testing.T) { + // Fixture Setup + // language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "%s", + "args": [ "amount", "5"] + } + `, strings.ToUpper(binOp)) + + ast, err := GetAst(jsonTxt) + require.NoError(t, err) + + // Execute SUT + err = ValidateAstFieldAndOperators(ast, map[string][]string{"amount": {binOp}}) + + // Verification + require.NoError(t, err) + }) + } +} + +func TestValidationReturnsNoErrorForBinaryOperatorWhenAstUsesAliasAndSatisfiesContraints(t *testing.T) { + + for _, binOp := range binOps { + t.Run(fmt.Sprintf("%s", binOp), func(t *testing.T) { + // Fixture Setup + // language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "%s", + "args": [ "amount", "5"] + } + `, strings.ToUpper(binOp)) + + ast, err := GetAst(jsonTxt) + require.NoError(t, err) + + // Execute SUT + err = ValidateAstFieldAndOperatorsWithAliases(ast, map[string][]string{"total": {binOp}}, map[string]string{"amount": "total"}) + + // Verification + require.NoError(t, err) + }) + } +} + +func TestValidationReturnsErrorForBinaryOperatorsFailedValueValidationWhenAstUseAliases(t *testing.T) { + + for _, binOp := range binOps { + t.Run(fmt.Sprintf("%s", binOp), func(t *testing.T) { + // Fixture Setup + // language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "%s", + "args": [ "years", "ancient"] + } + `, strings.ToUpper(binOp)) + + ast, err := GetAst(jsonTxt) + require.NoError(t, err) + + // Execute SUT + err = ValidateAstFieldAndOperatorsWithAliasesAndValueValidation(ast, map[string][]string{"age": {binOp}}, map[string]string{"years": "age"}, map[string]string{"age": "number"}) + + // Verification + require.ErrorContains(t, err, fmt.Sprintf("could not validate [years] with [%s]", binOp)) + }) + } +} + +func TestValidationReturnsNoErrorForBinaryOperatorsWhenAstUseAliasesAndValueValidationAndSatisfiesConstraints(t *testing.T) { + + for _, binOp := range binOps { + t.Run(fmt.Sprintf("%s", binOp), func(t *testing.T) { + // Fixture Setup + // language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "%s", + "args": [ "years", "70"] + } + `, strings.ToUpper(binOp)) + + ast, err := GetAst(jsonTxt) + require.NoError(t, err) + + // Execute SUT + err = ValidateAstFieldAndOperatorsWithAliasesAndValueValidation(ast, map[string][]string{"age": {binOp}}, map[string]string{"years": "age"}, map[string]string{"age": "number"}) + + // Verification + require.NoError(t, err) + }) + } +} + +func TestValidationReturnsErrorForVariableOperatorsWhenAstUsesInvalidOperatorForKnownField(t *testing.T) { + for idx, varOp := range varOps { + t.Run(fmt.Sprintf("%s", varOp), func(t *testing.T) { + // Fixture Setup + // language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "%s", + "args": ["amount", "5"] + } + `, strings.ToUpper(varOp)) + + ast, err := GetAst(jsonTxt) + require.NoError(t, err) + + otherBinOp := binOps[(idx+1)%len(binOps)] + + // Execute SUT + err = ValidateAstFieldAndOperators(ast, map[string][]string{"amount": {otherBinOp}}) + + // Verification + require.ErrorContains(t, err, fmt.Sprintf("unknown operator [%s] specified in search filter for field [amount], allowed operators are [%s]", varOp, otherBinOp)) + }) + } + +} + +func TestValidationReturnsErrorForVariableOperatorsWhenAstUsesUnknownField(t *testing.T) { + for idx, varOp := range varOps { + t.Run(fmt.Sprintf("%s", varOp), func(t *testing.T) { + // Fixture Setup + // language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "%s", + "args": ["amount", "5"] + } + `, strings.ToUpper(varOp)) + + ast, err := GetAst(jsonTxt) + require.NoError(t, err) + + otherBinOp := binOps[(idx+1)%len(binOps)] + + // Execute SUT + err = ValidateAstFieldAndOperators(ast, map[string][]string{"other_field": {otherBinOp}}) + + // Verification + require.ErrorContains(t, err, fmt.Sprintf("unknown field [amount] specified in search filter, allowed fields are [other_field]")) + }) + } + +} + +func TestValidationReturnsNoErrorForVariableOperatorWhenAstSatisfiesConstraints(t *testing.T) { + + for _, varOp := range varOps { + t.Run(fmt.Sprintf("%s", varOp), func(t *testing.T) { + // Fixture Setup + // language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "%s", + "args": ["amount", "5"] + } + `, strings.ToUpper(varOp)) + + ast, err := GetAst(jsonTxt) + require.NoError(t, err) + + // Execute SUT + err = ValidateAstFieldAndOperators(ast, map[string][]string{"amount": {varOp}}) + + // Verification + require.NoError(t, err) + }) + } +} + +func TestValidationReturnsNoErrorForVariableOperatorWhenAstUsesAliasesAndSatisfiesConstraints(t *testing.T) { + + for _, varOp := range varOps { + t.Run(fmt.Sprintf("%s", varOp), func(t *testing.T) { + // Fixture Setup + // language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "%s", + "args": ["amount", "5"] + } + `, strings.ToUpper(varOp)) + + ast, err := GetAst(jsonTxt) + require.NoError(t, err) + + // Execute SUT + err = ValidateAstFieldAndOperatorsWithAliases(ast, map[string][]string{"total": {varOp}}, map[string]string{"amount": "total"}) + + // Verification + require.NoError(t, err) + }) + } +} + +func TestValidationReturnsErrorForVariableOperatorsFailedValueValidationWhenAstUseAliases(t *testing.T) { + + for _, varOp := range varOps { + t.Run(fmt.Sprintf("%s", varOp), func(t *testing.T) { + // Fixture Setup + // language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "%s", + "args": ["email", "foo@foo.com", "bar@bar.com", "5"] + } + `, strings.ToUpper(varOp)) + + ast, err := GetAst(jsonTxt) + require.NoError(t, err) + + // Execute SUT + err = ValidateAstFieldAndOperatorsWithValueValidation(ast, map[string][]string{"email": {varOp}}, map[string]string{"email": "email"}) + + // Verification + require.ErrorContains(t, err, fmt.Sprintf("could not validate [email] with [%s]", varOp)) + }) + } +} + +func TestValidationReturnsNoErrorForVariableOperatorsWhenAstUseAliasesAndValueValidationAndSatisfiesConstraints(t *testing.T) { + + for _, varOp := range varOps { + t.Run(fmt.Sprintf("%s", varOp), func(t *testing.T) { + // Fixture Setup + // language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "%s", + "args": [ "order_status", "complete", "cancelled"] + } + `, strings.ToUpper(varOp)) + + ast, err := GetAst(jsonTxt) + require.NoError(t, err) + + // Execute SUT + err = ValidateAstFieldAndOperatorsWithAliasesAndValueValidation(ast, map[string][]string{"status": {varOp}}, map[string]string{"order_status": "status"}, map[string]string{"status": "oneof=incomplete complete processing cancelled"}) + + // Verification + require.NoError(t, err) + }) + } +} + +func TestSmokeTestAndWithBinaryAndVariableReturnsErrorWhenBothAreInvalid(t *testing.T) { + for _, varOp := range varOps { + for _, binOp := range binOps { + + t.Run(fmt.Sprintf("%s/%s", varOp, binOp), func(t *testing.T) { + // Fixture Setup + // language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "AND", + "children": [ + { + "type": "%s", + "args": [ "status", "complete", "cancelled"] + }, + { + "type": "%s", + "args": [ "some_field", "hello"] + } + ] +}`, strings.ToUpper(varOp), strings.ToUpper(binOp)) + + ast, err := GetAst(jsonTxt) + require.NoError(t, err) + + // Execute SUT + err = ValidateAstFieldAndOperatorsWithValueValidation(ast, map[string][]string{"status": {varOp}, "other_field": {binOp}}, map[string]string{"status": "oneof=incomplete complete processing cancelled"}) + + // Verification + require.ErrorContains(t, err, fmt.Sprint("unknown field [some_field] specified in search filter")) + }) + + } + } +} + +func TestSmokeTestAndWithBinaryAndVariableReturnsNoErrorWhenBothValid(t *testing.T) { + for _, varOp := range varOps { + for _, binOp := range binOps { + + t.Run(fmt.Sprintf("%s/%s", varOp, binOp), func(t *testing.T) { + // Fixture Setup + // language=JSON + jsonTxt := fmt.Sprintf(` + { + "type": "AND", + "children": [ + { + "type": "%s", + "args": [ "status", "complete", "cancelled"] + }, + { + "type": "%s", + "args": [ "some_field", "hello"] + } + ] +}`, strings.ToUpper(varOp), strings.ToUpper(binOp)) + + ast, err := GetAst(jsonTxt) + require.NoError(t, err) + + // Execute SUT + err = ValidateAstFieldAndOperatorsWithValueValidation(ast, map[string][]string{"status": {varOp}, "some_field": {binOp}}, map[string]string{"status": "oneof=incomplete complete processing cancelled"}) + + // Verification + require.NoError(t, err) + }) + + } + } +} + +func TestNewConstructorDetectsUnknownAliasTarget(t *testing.T) { + // Fixture Setup + + // Execute SUT + err := ValidateAstFieldAndOperatorsWithAliases(nil, map[string][]string{"status": {"eq"}}, map[string]string{"total": "amount"}) + + // Verification + require.ErrorContains(t, err, fmt.Sprintf("alias from `total` to `amount` points to a field not in the allowed ops")) +} + +func TestNewConstructorDetectsUnknownValueValidatorTarget(t *testing.T) { + // Fixture Setup + // Execute SUT + err := ValidateAstFieldAndOperatorsWithValueValidation(nil, map[string][]string{"status": {"eq"}}, map[string]string{"total": "int"}) + + // Verification + require.ErrorContains(t, err, fmt.Sprintf("validator for field `total` with type `int` points to an unknown field")) +} + +func TestNewConstructorDetectsAliasedValueValidatorTarget(t *testing.T) { + // Fixture Setup + + // Execute SUT + err := ValidateAstFieldAndOperatorsWithAliasesAndValueValidation(nil, map[string][]string{"status": {"eq"}}, map[string]string{"state": "status"}, map[string]string{"state": "int"}) + + // Verification + require.ErrorContains(t, err, fmt.Sprintf("validator for field `state` with type `int` points to an alias of `status` instead of the field")) +} diff --git a/external/epsearchast/v3/validating_visitor.go b/external/epsearchast/v3/validating_visitor.go index a18c722..e064c30 100644 --- a/external/epsearchast/v3/validating_visitor.go +++ b/external/epsearchast/v3/validating_visitor.go @@ -10,7 +10,6 @@ import ( type validatingVisitor struct { AllowedOperators map[string][]string ColumnAliases map[string]string - Visitor AstVisitor ValueValidators map[string]string } @@ -23,7 +22,8 @@ func init() { var _ AstVisitor = (*validatingVisitor)(nil) -func NewValidatingVisitor(visitor AstVisitor, allowedOps map[string][]string, aliases map[string]string, valueValidators map[string]string) (AstVisitor, error) { +// Returns a new validatingVisitor though you should use the helper functions (e.g., [ValidateAstFieldAndOperators]) instead of this method +func NewValidatingVisitor(allowedOps map[string][]string, aliases map[string]string, valueValidators map[string]string) (AstVisitor, error) { for k, v := range aliases { if _, ok := allowedOps[v]; !ok { @@ -44,7 +44,6 @@ func NewValidatingVisitor(visitor AstVisitor, allowedOps map[string][]string, al } return &validatingVisitor{ - Visitor: visitor, AllowedOperators: allowedOps, ColumnAliases: aliases, ValueValidators: valueValidators, @@ -52,89 +51,89 @@ func NewValidatingVisitor(visitor AstVisitor, allowedOps map[string][]string, al } func (v *validatingVisitor) PreVisit() error { - return v.Visitor.PreVisit() + return nil } func (v *validatingVisitor) PostVisit() error { - return v.Visitor.PostVisit() + return nil } func (v *validatingVisitor) PreVisitAnd(astNode *AstNode) (bool, error) { - return v.Visitor.PreVisitAnd(astNode) + return true, nil } -func (v *validatingVisitor) PostVisitAnd(astNode *AstNode) (bool, error) { - return v.Visitor.PostVisitAnd(astNode) +func (v *validatingVisitor) PostVisitAnd(astNode *AstNode) error { + return nil } func (v *validatingVisitor) VisitIn(astNode *AstNode) (bool, error) { fieldName := astNode.Args[0] - if _, err := v.validateFieldAndValue("in", fieldName, astNode.Args[1:]...); err != nil { + if err := v.validateFieldAndValue("in", fieldName, astNode.Args[1:]...); err != nil { return false, err } - return v.Visitor.VisitIn(astNode) + return false, nil } func (v *validatingVisitor) VisitEq(astNode *AstNode) (bool, error) { fieldName := astNode.Args[0] - if _, err := v.validateFieldAndValue("eq", fieldName, astNode.Args[1]); err != nil { + if err := v.validateFieldAndValue("eq", fieldName, astNode.Args[1]); err != nil { return false, err } - return v.Visitor.VisitEq(astNode) + return false, nil } func (v *validatingVisitor) VisitLe(astNode *AstNode) (bool, error) { fieldName := astNode.Args[0] - if _, err := v.validateFieldAndValue("le", fieldName, astNode.Args[1]); err != nil { + if err := v.validateFieldAndValue("le", fieldName, astNode.Args[1]); err != nil { return false, err } - return v.Visitor.VisitLe(astNode) + return false, nil } func (v *validatingVisitor) VisitLt(astNode *AstNode) (bool, error) { fieldName := astNode.Args[0] - if _, err := v.validateFieldAndValue("lt", fieldName, astNode.Args[1]); err != nil { + if err := v.validateFieldAndValue("lt", fieldName, astNode.Args[1]); err != nil { return false, err } - return v.Visitor.VisitLt(astNode) + return false, nil } func (v *validatingVisitor) VisitGe(astNode *AstNode) (bool, error) { fieldName := astNode.Args[0] - if _, err := v.validateFieldAndValue("ge", fieldName, astNode.Args[1]); err != nil { + if err := v.validateFieldAndValue("ge", fieldName, astNode.Args[1]); err != nil { return false, err } - return v.Visitor.VisitGe(astNode) + return false, nil } func (v *validatingVisitor) VisitGt(astNode *AstNode) (bool, error) { fieldName := astNode.Args[0] - if _, err := v.validateFieldAndValue("gt", fieldName, astNode.Args[1]); err != nil { + if err := v.validateFieldAndValue("gt", fieldName, astNode.Args[1]); err != nil { return false, err } - return v.Visitor.VisitGt(astNode) + return false, nil } func (v *validatingVisitor) VisitLike(astNode *AstNode) (bool, error) { fieldName := astNode.Args[0] - if _, err := v.validateFieldAndValue("like", fieldName, astNode.Args[1]); err != nil { + if err := v.validateFieldAndValue("like", fieldName, astNode.Args[1]); err != nil { return false, err } - return v.Visitor.VisitLike(astNode) + return false, nil } func (v *validatingVisitor) isOperatorValidForField(operator, requestField string) (bool, error) { @@ -157,10 +156,10 @@ func (v *validatingVisitor) isOperatorValidForField(operator, requestField strin return false, fmt.Errorf("unknown operator [%s] specified in search filter for field [%s], allowed operators are %v", strings.ToLower(operator), requestField, v.AllowedOperators[canonicalField]) } -func (v *validatingVisitor) validateFieldAndValue(operator, requestField string, values ...string) (bool, error) { +func (v *validatingVisitor) validateFieldAndValue(operator, requestField string, values ...string) error { if _, err := v.isOperatorValidForField(operator, requestField); err != nil { - return false, err + return err } canonicalField := requestField @@ -177,15 +176,15 @@ func (v *validatingVisitor) validateFieldAndValue(operator, requestField string, if verrors, ok := err.(validator.ValidationErrors); ok { if len(verrors) > 0 { verror := verrors[0] - return false, fmt.Errorf("could not validate [%s] with [%s], value [%s] does not satisify requirement [%s]", requestField, operator, verror.Value(), verror.Tag()) + return fmt.Errorf("could not validate [%s] with [%s], value [%s] does not satisify requirement [%s]", requestField, operator, verror.Value(), verror.Tag()) } } - return false, fmt.Errorf("could not validate [%s] with [%s] validation error: %w", requestField, value, err) + return fmt.Errorf("could not validate [%s] with [%s] validation error: %w", requestField, value, err) } } } - return true, nil + return nil } diff --git a/external/epsearchast/v3/validating_visitor_test.go b/external/epsearchast/v3/validating_visitor_test.go deleted file mode 100644 index 7573b6c..0000000 --- a/external/epsearchast/v3/validating_visitor_test.go +++ /dev/null @@ -1,481 +0,0 @@ -package epsearchast_v3 - -import ( - "fmt" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - "strings" - "testing" -) - -var binOps = []string{"le", "lt", "eq", "ge", "gt", "like"} - -var varOps = []string{"in"} - -func TestValidationCatchesInvalidOperatorForBinaryOperatorsForKnownField(t *testing.T) { - for idx, binOp := range binOps { - t.Run(fmt.Sprintf("%s", binOp), func(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := fmt.Sprintf(` - { - "type": "%s", - "args": [ "amount", "5"] - } - `, strings.ToUpper(binOp)) - - astNode, err := GetAst(jsonTxt) - require.NoError(t, err) - - otherBinOp := binOps[(idx+1)%len(binOps)] - - mockObj := new(MyMockedVisitor) - mockObj.On("PreVisit").Return(nil). - On("PostVisit").Return(nil) - - visitor, err := NewValidatingVisitor(mockObj, map[string][]string{"amount": {otherBinOp}}, map[string]string{}, map[string]string{}) - require.NoError(t, err) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.ErrorContains(t, err, fmt.Sprintf("unknown operator [%s] specified in search filter for field [amount], allowed operators are [%s]", binOp, otherBinOp)) - }) - } - -} - -func TestValidationCatchesInvalidOperatorForBinaryOperatorsForUnknownField(t *testing.T) { - for idx, binOp := range binOps { - t.Run(fmt.Sprintf("%s", binOp), func(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := fmt.Sprintf(` - { - "type": "%s", - "args": [ "amount", "5"] - } - `, strings.ToUpper(binOp)) - - astNode, err := GetAst(jsonTxt) - require.NoError(t, err) - - otherBinOp := binOps[(idx+1)%len(binOps)] - - mockObj := new(MyMockedVisitor) - mockObj.On("PreVisit").Return(nil). - On("PostVisit").Return(nil) - - visitor, err := NewValidatingVisitor(mockObj, map[string][]string{"other_field": {otherBinOp}}, map[string]string{}, map[string]string{}) - require.NoError(t, err) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.ErrorContains(t, err, fmt.Sprintf("unknown field [amount] specified in search filter, allowed fields are [other_field]")) - }) - } - -} - -func TestValidationReturnsErrorForBinaryOperators(t *testing.T) { - - for _, binOp := range binOps { - t.Run(fmt.Sprintf("%s", binOp), func(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := fmt.Sprintf(` - { - "type": "%s", - "args": [ "amount", "5"] - } - `, strings.ToUpper(binOp)) - - astNode, err := GetAst(jsonTxt) - require.NoError(t, err) - - mockObj := new(MyMockedVisitor) - mockObj.On("PreVisit").Return(nil). - On("PostVisit").Return(nil). - On(fmt.Sprintf("Visit%s", strings.Title(binOp)), mock.Anything).Return(true, fmt.Errorf("mocked error: %s", binOp)) - - visitor, err := NewValidatingVisitor(mockObj, map[string][]string{"amount": {binOp}}, map[string]string{}, map[string]string{}) - require.NoError(t, err) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.ErrorContains(t, err, fmt.Sprintf("mocked error: %s", binOp)) - }) - } -} - -func TestValidationReturnsErrorForBinaryOperatorsWithAlias(t *testing.T) { - - for _, binOp := range binOps { - t.Run(fmt.Sprintf("%s", binOp), func(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := fmt.Sprintf(` - { - "type": "%s", - "args": [ "amount", "5"] - } - `, strings.ToUpper(binOp)) - - astNode, err := GetAst(jsonTxt) - require.NoError(t, err) - - mockObj := new(MyMockedVisitor) - mockObj.On("PreVisit").Return(nil). - On("PostVisit").Return(nil). - On(fmt.Sprintf("Visit%s", strings.Title(binOp)), mock.Anything).Return(true, fmt.Errorf("mocked error: %s", binOp)) - - visitor, err := NewValidatingVisitor(mockObj, map[string][]string{"total": {binOp}}, map[string]string{"amount": "total"}, map[string]string{}) - require.NoError(t, err) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.ErrorContains(t, err, fmt.Sprintf("mocked error: %s", binOp)) - }) - } -} - -func TestValidationReturnsErrorForBinaryOperatorsValueValidation(t *testing.T) { - - for _, binOp := range binOps { - t.Run(fmt.Sprintf("%s", binOp), func(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := fmt.Sprintf(` - { - "type": "%s", - "args": [ "amount", "5"] - } - `, strings.ToUpper(binOp)) - - astNode, err := GetAst(jsonTxt) - require.NoError(t, err) - - mockObj := new(MyMockedVisitor) - mockObj.On("PreVisit").Return(nil). - On("PostVisit").Return(nil) - - visitor, err := NewValidatingVisitor(mockObj, map[string][]string{"total": {binOp}}, map[string]string{"amount": "total"}, map[string]string{"total": "uuid"}) - require.NoError(t, err) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.ErrorContains(t, err, fmt.Sprintf("could not validate [amount] with [%s]", binOp)) - }) - } -} - -func TestValidationCatchesInvalidOperatorForVariableOperatorsForKnownField(t *testing.T) { - for idx, varOp := range varOps { - t.Run(fmt.Sprintf("%s", varOp), func(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := fmt.Sprintf(` - { - "type": "%s", - "args": ["amount", "5"] - } - `, strings.ToUpper(varOp)) - - astNode, err := GetAst(jsonTxt) - require.NoError(t, err) - - otherBinOp := binOps[(idx+1)%len(binOps)] - - mockObj := new(MyMockedVisitor) - mockObj.On("PreVisit").Return(nil). - On("PostVisit").Return(nil) - - visitor, err := NewValidatingVisitor(mockObj, map[string][]string{"amount": {otherBinOp}}, map[string]string{}, map[string]string{}) - require.NoError(t, err) - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.ErrorContains(t, err, fmt.Sprintf("unknown operator [%s] specified in search filter for field [amount], allowed operators are [%s]", varOp, otherBinOp)) - }) - } - -} - -func TestValidationCatchesInvalidOperatorForVariableOperatorsForUnknownField(t *testing.T) { - for idx, varOp := range varOps { - t.Run(fmt.Sprintf("%s", varOp), func(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := fmt.Sprintf(` - { - "type": "%s", - "args": ["amount", "5"] - } - `, strings.ToUpper(varOp)) - - astNode, err := GetAst(jsonTxt) - require.NoError(t, err) - - otherBinOp := binOps[(idx+1)%len(binOps)] - - mockObj := new(MyMockedVisitor) - mockObj.On("PreVisit").Return(nil). - On("PostVisit").Return(nil) - - visitor, err := NewValidatingVisitor(mockObj, map[string][]string{"other_field": {otherBinOp}}, map[string]string{}, map[string]string{}) - require.NoError(t, err) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.ErrorContains(t, err, fmt.Sprintf("unknown field [amount] specified in search filter, allowed fields are [other_field]")) - }) - } - -} - -func TestValidationReturnsErrorForVariableOperators(t *testing.T) { - - for _, varOp := range varOps { - t.Run(fmt.Sprintf("%s", varOp), func(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := fmt.Sprintf(` - { - "type": "%s", - "args": ["amount", "5"] - } - `, strings.ToUpper(varOp)) - - astNode, err := GetAst(jsonTxt) - require.NoError(t, err) - - mockObj := new(MyMockedVisitor) - mockObj.On("PreVisit").Return(nil). - On("PostVisit").Return(nil). - On(fmt.Sprintf("Visit%s", strings.Title(varOp)), mock.Anything).Return(true, fmt.Errorf("mocked error: %s", varOp)) - - visitor, err := NewValidatingVisitor(mockObj, map[string][]string{"amount": {varOp}}, map[string]string{}, map[string]string{}) - require.NoError(t, err) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.ErrorContains(t, err, fmt.Sprintf("mocked error: %s", varOp)) - }) - } -} - -func TestValidationReturnsErrorForVariableOperatorsWithAlias(t *testing.T) { - - for _, varOp := range varOps { - t.Run(fmt.Sprintf("%s", varOp), func(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := fmt.Sprintf(` - { - "type": "%s", - "args": ["amount", "5"] - } - `, strings.ToUpper(varOp)) - - astNode, err := GetAst(jsonTxt) - require.NoError(t, err) - - mockObj := new(MyMockedVisitor) - mockObj.On("PreVisit").Return(nil). - On("PostVisit").Return(nil). - On(fmt.Sprintf("Visit%s", strings.Title(varOp)), mock.Anything).Return(true, fmt.Errorf("mocked error: %s", varOp)) - - visitor, err := NewValidatingVisitor(mockObj, map[string][]string{"total": {varOp}}, map[string]string{"amount": "total"}, map[string]string{}) - require.NoError(t, err) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.ErrorContains(t, err, fmt.Sprintf("mocked error: %s", varOp)) - }) - } -} - -func TestValidationReturnsErrorForVariableOperatorsValueValidation(t *testing.T) { - - for _, varOp := range varOps { - t.Run(fmt.Sprintf("%s", varOp), func(t *testing.T) { - // Fixture Setup - // language=JSON - jsonTxt := fmt.Sprintf(` - { - "type": "%s", - "args": ["email", "foo@foo.com", "bar@bar.com", "5"] - } - `, strings.ToUpper(varOp)) - - astNode, err := GetAst(jsonTxt) - require.NoError(t, err) - - mockObj := new(MyMockedVisitor) - mockObj.On("PreVisit").Return(nil). - On("PostVisit").Return(nil) - - visitor, err := NewValidatingVisitor(mockObj, map[string][]string{"email": {varOp}}, map[string]string{}, map[string]string{"email": "email"}) - require.NoError(t, err) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.ErrorContains(t, err, fmt.Sprintf("could not validate [email] with [%s]", varOp)) - }) - } -} - -func TestValidationReturnsErrorForPostVisit(t *testing.T) { - - // Fixture Setup - // language=JSON - jsonTxt := ` - { - "type": "IN", - "args": ["amount", "5"] - }` - - astNode, err := GetAst(jsonTxt) - require.NoError(t, err) - - mockObj := new(MyMockedVisitor) - mockObj.On("PreVisit").Return(nil). - On("PostVisit").Return(fmt.Errorf("mocked error: PostVisit")). - On("VisitIn", mock.Anything).Return(true, nil) - - visitor, err := NewValidatingVisitor(mockObj, map[string][]string{"amount": {"in"}}, map[string]string{}, map[string]string{}) - require.NoError(t, err) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.ErrorContains(t, err, fmt.Sprintf("mocked error: PostVisit")) - -} - -func TestValidationReturnsErrorForPostVisitAnd(t *testing.T) { - - // Fixture Setup - // language=JSON - jsonTxt := ` - { - "type": "AND", - "children": [ - { - "type": "IN", - "args": ["amount", "5"] - }, - { - "type": "EQ", - "args": [ "status", "paid"] - } - ] -}` - astNode, err := GetAst(jsonTxt) - require.NoError(t, err) - - mockObj := new(MyMockedVisitor) - mockObj.On("PreVisit").Return(nil). - On("VisitIn", mock.Anything).Return(true, nil). - On("VisitEq", mock.Anything).Return(true, nil). - On("PreVisitAnd", mock.Anything).Return(true, nil). - On("PostVisitAnd", mock.Anything).Return(false, fmt.Errorf("mocked error: PostVisitAnd")) - - visitor, err := NewValidatingVisitor(mockObj, map[string][]string{"amount": {"in"}, "status": {"eq"}}, map[string]string{}, map[string]string{}) - require.NoError(t, err) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.ErrorContains(t, err, fmt.Sprintf("mocked error: PostVisitAnd")) - -} - -func TestValidationReturnsErrorForPreVisitAnd(t *testing.T) { - - // Fixture Setup - // language=JSON - jsonTxt := ` - { - "type": "AND", - "children": [ - { - "type": "IN", - "args": ["amount", "5"] - }, - { - "type": "EQ", - "args": [ "status", "paid"] - } - ] -}` - astNode, err := GetAst(jsonTxt) - require.NoError(t, err) - - mockObj := new(MyMockedVisitor) - mockObj.On("PreVisit").Return(nil). - On("PreVisitAnd", mock.Anything).Return(false, fmt.Errorf("mocked error: PreVisitAnd")) - - visitor, err := NewValidatingVisitor(mockObj, map[string][]string{"amount": {"in"}, "status": {"eq"}}, map[string]string{}, map[string]string{}) - require.NoError(t, err) - - // Execute SUT - err = astNode.Accept(visitor) - - // Verification - require.ErrorContains(t, err, fmt.Sprintf("mocked error: PreVisitAnd")) - -} - -func TestNewConstructorDetectsUnknownAliasTarget(t *testing.T) { - // Fixture Setup - mockObj := new(MyMockedVisitor) - - // Execute SUT - _, err := NewValidatingVisitor(mockObj, map[string][]string{"status": {"eq"}}, map[string]string{"total": "amount"}, map[string]string{}) - - // Verification - require.ErrorContains(t, err, fmt.Sprintf("alias from `total` to `amount` points to a field not in the allowed ops")) -} - -func TestNewConstructorDetectsUnknownValueValidatorTarget(t *testing.T) { - // Fixture Setup - mockObj := new(MyMockedVisitor) - - // Execute SUT - _, err := NewValidatingVisitor(mockObj, map[string][]string{"status": {"eq"}}, map[string]string{}, map[string]string{"total": "int"}) - - // Verification - require.ErrorContains(t, err, fmt.Sprintf("validator for field `total` with type `int` points to an unknown field")) -} - -func TestNewConstructorDetectsAliasedValueValidatorTarget(t *testing.T) { - // Fixture Setup - mockObj := new(MyMockedVisitor) - - // Execute SUT - _, err := NewValidatingVisitor(mockObj, map[string][]string{"status": {"eq"}}, map[string]string{"state": "status"}, map[string]string{"state": "int"}) - - // Verification - require.ErrorContains(t, err, fmt.Sprintf("validator for field `state` with type `int` points to an alias of `status` instead of the field")) -} diff --git a/external/epsearchast/v3/visitor_facade.go b/external/epsearchast/v3/visitor_facade.go deleted file mode 100644 index ac87077..0000000 --- a/external/epsearchast/v3/visitor_facade.go +++ /dev/null @@ -1,69 +0,0 @@ -package epsearchast_v3 - -type SearchFilterVisitor interface { - PreVisit() error - PostVisit() error - PreVisitAnd() error - PostVisitAnd() error - VisitIn(args ...string) error - VisitEq(first, second string) error - VisitLe(first, second string) error - VisitLt(first, second string) error - VisitGe(first, second string) error - VisitGt(first, second string) error - VisitLike(first, second string) error -} - -func NewSearchFilterVisitorAdapter(visitor SearchFilterVisitor) AstVisitor { - return &SearchFilterVisitorAdaptor{Sfv: visitor} -} - -type SearchFilterVisitorAdaptor struct { - Sfv SearchFilterVisitor -} - -func (s *SearchFilterVisitorAdaptor) PreVisit() error { - return s.Sfv.PreVisit() -} - -func (s *SearchFilterVisitorAdaptor) PostVisit() error { - return s.Sfv.PostVisit() -} - -func (s *SearchFilterVisitorAdaptor) PreVisitAnd(_ *AstNode) (bool, error) { - return true, s.Sfv.PreVisitAnd() -} - -func (s *SearchFilterVisitorAdaptor) PostVisitAnd(_ *AstNode) (bool, error) { - return true, s.Sfv.PostVisitAnd() -} - -func (s *SearchFilterVisitorAdaptor) VisitIn(astNode *AstNode) (bool, error) { - return false, s.Sfv.VisitIn(astNode.Args...) -} - -func (s *SearchFilterVisitorAdaptor) VisitEq(astNode *AstNode) (bool, error) { - return false, s.Sfv.VisitEq(astNode.Args[0], astNode.Args[1]) -} - -func (s *SearchFilterVisitorAdaptor) VisitLe(astNode *AstNode) (bool, error) { - return false, s.Sfv.VisitLe(astNode.Args[0], astNode.Args[1]) -} - -func (s *SearchFilterVisitorAdaptor) VisitLt(astNode *AstNode) (bool, error) { - return false, s.Sfv.VisitLt(astNode.Args[0], astNode.Args[1]) -} - -func (s *SearchFilterVisitorAdaptor) VisitGe(astNode *AstNode) (bool, error) { - return false, s.Sfv.VisitGe(astNode.Args[0], astNode.Args[1]) -} - -func (s *SearchFilterVisitorAdaptor) VisitGt(astNode *AstNode) (bool, error) { - return false, s.Sfv.VisitGt(astNode.Args[0], astNode.Args[1]) -} - -func (s *SearchFilterVisitorAdaptor) VisitLike(astNode *AstNode) (bool, error) { - return false, s.Sfv.VisitLike(astNode.Args[0], astNode.Args[1]) -} - -var _ AstVisitor = (*SearchFilterVisitorAdaptor)(nil)