Skip to content

Commit

Permalink
Resolves #35 - Add support for text() (#36)
Browse files Browse the repository at this point in the history
  • Loading branch information
steve-r-west authored Feb 20, 2024
1 parent 19c1d12 commit 659edec
Show file tree
Hide file tree
Showing 11 changed files with 164 additions and 2 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ func Example(ast *epsearchast_v3.AstNode, query *gorm.DB, tenantBoundaryId strin
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).
4. The `text` operator implementation makes a number of assumptions, and you likely will want to override it's implementation:
* English is hard coded as the language.
* Postgres recommends using a [distinct tsvector column and using a stored generated column](https://www.postgresql.org/docs/current/textsearch-tables.html#TEXTSEARCH-TABLES-INDEX). The current implementation does not support this and, you would need to override the method to support it. A simple MR could be made to allow for the Gorm query builder to know if there is a tsvector column and use that.

##### Advanced Customization

Expand Down Expand Up @@ -260,6 +263,7 @@ func Example(ast *epsearchast_v3.AstNode, collection *mongo.Collection, tenantBo
##### Limitations

1. The Mongo Query builder is designed to produce filter compatible with the [filter argument in a Query](https://www.mongodb.com/docs/drivers/go/current/fundamentals/crud/read-operations/query-document/#specify-a-query), if a field in the API is a projection that requires computation via the aggregation pipeline, then we would likely need code changes to support that.
2. The [$text](https://www.mongodb.com/docs/v7.0/reference/operator/query/text/#behavior) operator in Mongo has a number of limitations that make it unsuitable for arbitrary queries. In particular in mongo you can only search a collection, not fields for text data, and you must declare a text index. This means that any supplied field in the filter, is just dropped. It is recommended that when using `text` with Mongo, you only allow users to search `text(*,search)` , i.e., force them to use a wildcard as the field name. It is also recommended that you use a [Wildcard](https://www.mongodb.com/docs/manual/core/indexes/index-types/index-text/create-wildcard-text-index/) index to avoid the need of having to remove and modify it over time.

##### Advanced Customization

Expand Down
6 changes: 5 additions & 1 deletion external/epsearchast/v3/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ type AstVisitor interface {
VisitGe(astNode *AstNode) (bool, error)
VisitGt(astNode *AstNode) (bool, error)
VisitLike(astNode *AstNode) (bool, error)

VisitText(astNode *AstNode) (bool, error)
VisitIsNull(astNode *AstNode) (bool, error)
}

Expand Down Expand Up @@ -89,6 +91,8 @@ func (a *AstNode) accept(v AstVisitor) error {
descend, err = v.VisitGe(a)
case "LIKE":
descend, err = v.VisitLike(a)
case "TEXT":
descend, err = v.VisitText(a)
case "IS_NULL":
descend, err = v.VisitIsNull(a)
default:
Expand Down Expand Up @@ -140,7 +144,7 @@ func (a *AstNode) checkValid() error {
if len(a.Args) < 2 {
return fmt.Errorf("insufficient number of arguments to %s", strings.ToLower(a.NodeType))
}
case "EQ", "LE", "LT", "GT", "GE", "LIKE":
case "EQ", "LE", "LT", "GT", "GE", "LIKE", "TEXT":
if len(a.Children) > 0 {
return fmt.Errorf("operator %v should not have any children", strings.ToLower(a.NodeType))
}
Expand Down
17 changes: 17 additions & 0 deletions external/epsearchast/v3/ast_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,23 @@ func TestValidObjectWithLikeReturnsAst(t *testing.T) {
require.NotNil(t, astNode)
}

func TestValidObjectWithTextReturnsAst(t *testing.T) {
// Fixture Setup
// language=JSON
jsonTxt := `
{
"type": "TEXT",
"args": [ "name", "John"]
}
`
// Execute SUT
astNode, err := GetAst(jsonTxt)

// Verify
require.NoError(t, err)
require.NotNil(t, astNode)
}

func TestEqWithChildReturnsError(t *testing.T) {
// Fixture Setup
// language=JSON
Expand Down
56 changes: 56 additions & 0 deletions external/epsearchast/v3/ast_visitor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,32 @@ func TestPreAndPostAndLikeCalledOnAccept(t *testing.T) {

}

func TestPreAndPostAndTextCalledOnAccept(t *testing.T) {
// Fixture Setup
// language=JSON
jsonTxt := `
{
"type": "TEXT",
"args": [ "name", "Jon"]
}
`

mockObj := new(MyMockedVisitor)
mockObj.On("PreVisit").Return(nil).
On("PostVisit").Return(nil).
On("VisitText", mock.Anything).Return(true, nil)

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

// Execute SUT
err = astNode.Accept(mockObj)

// Verification
require.NoError(t, err)

}

func TestPreAndPostAndInCalledOnAccept(t *testing.T) {
// Fixture Setup
// language=JSON
Expand Down Expand Up @@ -388,6 +414,31 @@ func TestPreAndLikeCalledOnAcceptWithError(t *testing.T) {

}

func TestPreAndTextCalledOnAcceptWithError(t *testing.T) {
// Fixture Setup
// language=JSON
jsonTxt := `
{
"type": "TEXT",
"args": [ "name", "John"]
}
`

mockObj := new(MyMockedVisitor)
mockObj.On("PreVisit").Return(nil).
On("VisitText", mock.Anything).Return(true, fmt.Errorf("foo"))

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

// Execute SUT
err = astNode.Accept(mockObj)

// Verification
require.ErrorContains(t, err, "foo")

}

func TestPreAndInCalledOnAcceptWithError(t *testing.T) {
// Fixture Setup
// language=JSON
Expand Down Expand Up @@ -606,6 +657,11 @@ func (m *MyMockedVisitor) VisitLike(astNode *AstNode) (bool, error) {
return args.Bool(0), args.Error(1)
}

func (m *MyMockedVisitor) VisitText(astNode *AstNode) (bool, error) {
args := m.Called(astNode)
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)
Expand Down
7 changes: 7 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,13 @@ func (g DefaultGormQueryBuilder) VisitLike(first, second string) (*SubQuery, err
}, nil
}

func (g DefaultGormQueryBuilder) VisitText(first, second string) (*SubQuery, error) {
return &SubQuery{
Clause: fmt.Sprintf("to_tsvector('english', %s) @@ to_tsquery('english', ?)", first),
Args: []interface{}{second},
}, nil
}

func (g DefaultGormQueryBuilder) VisitIsNull(first string) (*SubQuery, error) {
return &SubQuery{
Clause: fmt.Sprintf("%s IS NULL", first),
Expand Down
27 changes: 27 additions & 0 deletions external/epsearchast/v3/gorm/gorm_query_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,33 @@ func TestLikeFilterWildCards(t *testing.T) {
t.Run("Wildcard Prefix & Suffix", genTest("*s*", "%s%"))
t.Run("No Wildcards", genTest("s", "s"))
}

func TestTextBinaryOperatorFiltersGeneratesCorrectWhereClause(t *testing.T) {

//Fixture Setup
//language=JSON
jsonTxt := fmt.Sprintf(`
{
"type": "%s",
"args": [ "name", "computer"]
}`, "TEXT")

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

var qb epsearchast_v3.SemanticReducer[SubQuery] = DefaultGormQueryBuilder{}

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

// Verification

require.NoError(t, err)

require.Equal(t, fmt.Sprintf(`to_tsvector('english', %s) @@ to_tsquery('english', ?)`, "name"), query.Clause)
require.Equal(t, []interface{}{"computer"}, query.Args)
}

func TestSimpleRecursiveStructure(t *testing.T) {
//Fixture Setup
//language=JSON
Expand Down
5 changes: 5 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,11 @@ func (d DefaultMongoQueryBuilder) VisitLike(first, second string) (*bson.D, erro
return &bson.D{{first, bson.D{{"$regex", d.ProcessLikeWildcards(second)}}}}, nil
}

func (d DefaultMongoQueryBuilder) VisitText(first, second string) (*bson.D, error) {
// https://www.mongodb.com/docs/v7.0/reference/operator/query/text/#std-label-text-operator-phrases
return &bson.D{{"$text", bson.D{{"$search", 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
Expand Down
29 changes: 29 additions & 0 deletions external/epsearchast/v3/mongo/mongo_query_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,35 @@ func TestSimpleBinaryOperatorFiltersGeneratesCorrectFilter(t *testing.T) {
}
}

func TestTextBinaryOperatorFiltersGeneratesCorrectFilter(t *testing.T) {
//Fixture Setup
//language=JSON
astJson := fmt.Sprintf(`
{
"type": "%s",
"args": [ "*", "computer"]
}`, "TEXT")

astNode, err := epsearchast_v3.GetAst(astJson)

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

expectedSearchJson := `{"$text":{"$search":"computer"}}`

// 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 TestLikeOperatorFiltersGeneratesCorrectFilter(t *testing.T) {

//Fixture Setup
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)
VisitText(first, second string) (*R, error)
VisitIsNull(first string) (*R, error)
}

Expand All @@ -34,6 +35,8 @@ func SemanticReduceAst[T any](a *AstNode, v SemanticReducer[T]) (*T, error) {
return v.VisitGt(a.Args[0], a.Args[1])
case "LIKE":
return v.VisitLike(a.Args[0], a.Args[1])
case "TEXT":
return v.VisitText(a.Args[0], a.Args[1])
case "IN":
return v.VisitIn(a.Args...)
case "AND":
Expand Down
2 changes: 1 addition & 1 deletion external/epsearchast/v3/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"testing"
)

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

var unaryOps = []string{"is_null"}

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 @@ -137,6 +137,16 @@ func (v *validatingVisitor) VisitLike(astNode *AstNode) (bool, error) {
return false, nil
}

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

if err := v.validateFieldAndValue("text", fieldName, astNode.Args[1]); err != nil {
return false, err
}

return false, nil
}

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

Expand Down

0 comments on commit 659edec

Please sign in to comment.