Skip to content

Commit

Permalink
Resolves #8 - Improves API for GORM
Browse files Browse the repository at this point in the history
  • Loading branch information
steve-r-west authored May 19, 2023
1 parent a4bd170 commit c3639d7
Show file tree
Hide file tree
Showing 16 changed files with 1,360 additions and 1,039 deletions.
213 changes: 212 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
37 changes: 37 additions & 0 deletions external/epsearchast/v3/aliases.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit c3639d7

Please sign in to comment.