Skip to content

Commit

Permalink
Unary operator is_null (#16)
Browse files Browse the repository at this point in the history
* Unary operator is_null

* Added is_null to mongo query builder

* Fix function that broke after rebasing

* Update external/epsearchast/v3/mongo/mongo_query_builder.go

Co-authored-by: Steve Ramage <[email protected]>

* Update external/epsearchast/v3/validate_test.go

Co-authored-by: Steve Ramage <[email protected]>

* Fixed linting issues

---------

Co-authored-by: Steve Ramage <[email protected]>
  • Loading branch information
mwan-ep and steve-r-west authored Jun 16, 2023
1 parent fe2d29e commit b6a3d3d
Show file tree
Hide file tree
Showing 10 changed files with 236 additions and 2 deletions.
12 changes: 10 additions & 2 deletions external/epsearchast/v3/aliases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ func TestApplyAliasesReturnsCorrectAstWhenAliasTwoFieldsAreAliasedInAnAnd(t *tes
{
"type": "EQ",
"args": [ "customer.email", "[email protected]"]
},
{
"type": "IS_NULL",
"args": [ "billing-email"]
}
]
}
Expand All @@ -140,7 +144,11 @@ func TestApplyAliasesReturnsCorrectAstWhenAliasTwoFieldsAreAliasedInAnAnd(t *tes
{
"type": "EQ",
"args": [ "customer.email", "[email protected]"]
}
},
{
"type": "IS_NULL",
"args": [ "billing.email"]
}
]
}`

Expand All @@ -151,7 +159,7 @@ func TestApplyAliasesReturnsCorrectAstWhenAliasTwoFieldsAreAliasedInAnAnd(t *tes
require.NoError(t, err)

// Execute SUT
aliasedAst, err := ApplyAliases(inputAstNode, map[string]string{"payment_status": "status", "customer_name": "customer.name"})
aliasedAst, err := ApplyAliases(inputAstNode, map[string]string{"payment_status": "status", "customer_name": "customer.name", "billing-email": "billing.email"})

// Verify
require.NoError(t, err)
Expand Down
12 changes: 12 additions & 0 deletions external/epsearchast/v3/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type AstVisitor interface {
VisitGe(astNode *AstNode) (bool, error)
VisitGt(astNode *AstNode) (bool, error)
VisitLike(astNode *AstNode) (bool, error)
VisitIsNull(astNode *AstNode) (bool, error)
}

// Accept triggers a visit of the AST.
Expand Down Expand Up @@ -88,6 +89,8 @@ func (a *AstNode) accept(v AstVisitor) error {
descend, err = v.VisitGe(a)
case "LIKE":
descend, err = v.VisitLike(a)
case "IS_NULL":
descend, err = v.VisitIsNull(a)
default:
return fmt.Errorf("unknown operator %s", a.NodeType)
}
Expand Down Expand Up @@ -145,6 +148,15 @@ func (a *AstNode) checkValid() error {
if len(a.Args) != 2 {
return fmt.Errorf("operator %v should have exactly 2 arguments", strings.ToLower(a.NodeType))

}
case "IS_NULL":
if len(a.Children) > 0 {
return fmt.Errorf("operator %v should not have any children", strings.ToLower(a.NodeType))
}

if len(a.Args) != 1 {
return fmt.Errorf("operator %v should have exactly 1 argument", strings.ToLower(a.NodeType))

}
default:
return fmt.Errorf("unknown operator %s", a.NodeType)
Expand Down
5 changes: 5 additions & 0 deletions external/epsearchast/v3/ast_visitor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -606,4 +606,9 @@ func (m *MyMockedVisitor) VisitLike(astNode *AstNode) (bool, error) {
return args.Bool(0), args.Error(1)
}

func (m *MyMockedVisitor) VisitIsNull(astNode *AstNode) (bool, error) {
args := m.Called(astNode)
return args.Bool(0), args.Error(1)
}

var _ AstVisitor = (*MyMockedVisitor)(nil)
6 changes: 6 additions & 0 deletions external/epsearchast/v3/gorm/gorm_query_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ func (g DefaultGormQueryBuilder) VisitLike(first, second string) (*SubQuery, err
}, nil
}

func (g DefaultGormQueryBuilder) VisitIsNull(first string) (*SubQuery, error) {
return &SubQuery{
Clause: fmt.Sprintf("%s IS NULL", first),
}, nil
}

func (g DefaultGormQueryBuilder) ProcessLikeWildcards(valString string) string {
if valString == "*" {
return "%"
Expand Down
33 changes: 33 additions & 0 deletions external/epsearchast/v3/gorm/gorm_query_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ var binOps = []testOp{
{"LIKE", "LIKE"},
}

var unaryOps = []testOp{
{"IS_NULL", "IS NULL"},
}

var varOps = []testOp{
{"IN", "IN"},
}
Expand Down Expand Up @@ -56,6 +60,35 @@ func TestSimpleBinaryOperatorFiltersGeneratesCorrectWhereClause(t *testing.T) {

}

func TestSimpleUnaryOperatorFiltersGeneratesCorrectWhereClause(t *testing.T) {
for _, unaryOp := range unaryOps {
t.Run(fmt.Sprintf("%s", unaryOp.AstOp), func(t *testing.T) {
//Fixture Setup
//language=JSON
jsonTxt := fmt.Sprintf(`
{
"type": "%s",
"args": [ "amount"]
}`, unaryOp.AstOp)

astNode, err := epsearchast_v3.GetAst(jsonTxt)
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", unaryOp.SqlOp), query.Clause)
})
}

}

func TestSimpleVariableOperatorFiltersGeneratesCorrectWhereClause(t *testing.T) {
for _, varOp := range varOps {
t.Run(fmt.Sprintf("%s", varOp.AstOp), func(t *testing.T) {
Expand Down
7 changes: 7 additions & 0 deletions external/epsearchast/v3/mongo/mongo_query_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ func (d DefaultMongoQueryBuilder) VisitLike(first, second string) (*bson.D, erro
return &bson.D{{first, bson.D{{"$regex", d.ProcessLikeWildcards(second)}}}}, nil
}

func (d DefaultMongoQueryBuilder) VisitIsNull(first string) (*bson.D, error) {
// https://www.mongodb.com/docs/manual/tutorial/query-for-null-fields/#equality-filter
// This will match fields that either contain the item field whose value is nil or those that do not contain the field
// Customize this method if you need different nil handling (i.e., explicit nil)
return &bson.D{{first, bson.D{{"$eq", nil}}}}, nil
}

func (d DefaultMongoQueryBuilder) ProcessLikeWildcards(valString string) string {
if valString == "*" {
return "^.*$"
Expand Down
36 changes: 36 additions & 0 deletions external/epsearchast/v3/mongo/mongo_query_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ var binOps = []testOp{
//{"LIKE", "$regex"},
}

var unaryOps = []testOp{
{"IS_NULL", `"$eq":null`},
}

var varOps = []testOp{
{"IN", "$in"},
}
Expand Down Expand Up @@ -95,6 +99,38 @@ func TestLikeOperatorFiltersGeneratesCorrectFilter(t *testing.T) {

}

func TestSimpleUnaryOperatorFiltersGeneratesCorrectFilter(t *testing.T) {
for _, unaryOp := range unaryOps {
t.Run(fmt.Sprintf("%s", unaryOp.AstOp), func(t *testing.T) {
//Fixture Setup
//language=JSON
astJson := fmt.Sprintf(`
{
"type": "%s",
"args": [ "amount"]
}`, unaryOp.AstOp)

astNode, err := epsearchast_v3.GetAst(astJson)

var qb epsearchast_v3.SemanticReducer[bson.D] = DefaultMongoQueryBuilder{}

expectedSearchJson := fmt.Sprintf(`{"amount":{%s}}`, unaryOp.MongoOp)

// Execute SUT
queryObj, err := epsearchast_v3.SemanticReduceAst(astNode, qb)

// Verification

require.NoError(t, err)

doc, err := bson.MarshalExtJSON(queryObj, true, false)
require.NoError(t, err)

require.Equal(t, expectedSearchJson, string(doc))
})
}
}

func TestSimpleVariableOperatorFiltersGeneratesCorrectFilter(t *testing.T) {
for _, varOp := range varOps {
t.Run(fmt.Sprintf("%s", varOp.AstOp), func(t *testing.T) {
Expand Down
3 changes: 3 additions & 0 deletions external/epsearchast/v3/semantic_reduce.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type SemanticReducer[R any] interface {
VisitGe(first, second string) (*R, error)
VisitGt(first, second string) (*R, error)
VisitLike(first, second string) (*R, error)
VisitIsNull(first string) (*R, error)
}

// SemanticReduceAst adapts an epsearchast_v3.SemanticReducer for use with the epsearchast_v3.ReduceAst function.
Expand All @@ -37,6 +38,8 @@ func SemanticReduceAst[T any](a *AstNode, v SemanticReducer[T]) (*T, error) {
return v.VisitIn(a.Args...)
case "AND":
return v.PostVisitAnd(t)
case "IS_NULL":
return v.VisitIsNull(a.Args[0])
default:
return nil, fmt.Errorf("unsupported node type: %s", a.NodeType)
}
Expand Down
114 changes: 114 additions & 0 deletions external/epsearchast/v3/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (

var binOps = []string{"le", "lt", "eq", "ge", "gt", "like"}

var unaryOps = []string{"is_null"}

var varOps = []string{"in"}

func TestValidationReturnsErrorForBinaryOperatorsWhenAstUsesInvalidOperatorForKnownField(t *testing.T) {
Expand Down Expand Up @@ -165,6 +167,118 @@ func TestValidationReturnsNoErrorForBinaryOperatorsWhenAstUseAliasesAndValueVali
}
}

func TestValidationReturnsNoErrorForUnaryOperatorWhenAstSatisfiesConstraints(t *testing.T) {

for _, unaryOp := range unaryOps {
t.Run(fmt.Sprintf("%s", unaryOp), func(t *testing.T) {
// Fixture Setup
// language=JSON
jsonTxt := fmt.Sprintf(`
{
"type": "%s",
"args": ["amount"]
}
`, strings.ToUpper(unaryOp))

ast, err := GetAst(jsonTxt)
require.NoError(t, err)

// Execute SUT
err = ValidateAstFieldAndOperators(ast, map[string][]string{"amount": {unaryOp}})

// Verification
require.NoError(t, err)
})
}
}

func TestValidationReturnsNoErrorForUnaryOperatorWhenAstUsesAliasesAndSatisfiesConstraints(t *testing.T) {

for _, unaryOp := range unaryOps {
t.Run(fmt.Sprintf("%s", unaryOp), func(t *testing.T) {
// Fixture Setup
// language=JSON
jsonTxt := fmt.Sprintf(`
{
"type": "%s",
"args": ["amount"]
}
`, strings.ToUpper(unaryOp))

ast, err := GetAst(jsonTxt)
require.NoError(t, err)

// Execute SUT
err = ValidateAstFieldAndOperatorsWithAliases(ast, map[string][]string{"total": {unaryOp}}, map[string]string{"amount": "total"})

// Verification
require.NoError(t, err)
})
}
}

func TestValidationReturnsNoErrorForUnaryOperatorsWhenAstUseAliasesAndValueValidationAndSatisfiesConstraints(t *testing.T) {

for _, unaryOp := range unaryOps {
t.Run(fmt.Sprintf("%s", unaryOp), func(t *testing.T) {
// Fixture Setup
// language=JSON
jsonTxt := fmt.Sprintf(`
{
"type": "%s",
"args": [ "order_status"]
}
`, strings.ToUpper(unaryOp))

ast, err := GetAst(jsonTxt)
require.NoError(t, err)

// Execute SUT
// Note: value validation doesn't do anything with is_null but importantly it doesn't crash which is what we test
err = ValidateAstFieldAndOperatorsWithAliasesAndValueValidation(ast, map[string][]string{"status": {unaryOp}}, map[string]string{"order_status": "status"}, map[string]string{"status": "oneof=incomplete complete processing cancelled"})

// Verification
require.NoError(t, err)
})
}
}

func TestSmokeTestAndWithUnaryAndVariableReturnsErrorWhenBothAreInvalid(t *testing.T) {
for _, varOp := range varOps {
for _, unaryOp := range unaryOps {

t.Run(fmt.Sprintf("%s/%s", varOp, unaryOp), 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"]
}
]
}`, strings.ToUpper(varOp), strings.ToUpper(unaryOp))

ast, err := GetAst(jsonTxt)
require.NoError(t, err)

// Execute SUT
err = ValidateAstFieldAndOperatorsWithValueValidation(ast, map[string][]string{"status": {varOp}, "other_field": {unaryOp}}, 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 TestValidationReturnsErrorForVariableOperatorsWhenAstUsesInvalidOperatorForKnownField(t *testing.T) {
for idx, varOp := range varOps {
t.Run(fmt.Sprintf("%s", varOp), func(t *testing.T) {
Expand Down
10 changes: 10 additions & 0 deletions external/epsearchast/v3/validating_visitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,16 @@ func (v *validatingVisitor) VisitLike(astNode *AstNode) (bool, error) {
return false, nil
}

func (v *validatingVisitor) VisitIsNull(astNode *AstNode) (bool, error) {
fieldName := astNode.Args[0]

if err := v.validateFieldAndValue("is_null", fieldName); err != nil {
return false, err
}

return false, nil
}

func (v *validatingVisitor) isOperatorValidForField(operator, requestField string) (bool, error) {

canonicalField := requestField
Expand Down

0 comments on commit b6a3d3d

Please sign in to comment.