Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: introduce dynamic GraphQL Builder #86

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
81 changes: 55 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,7 @@ client.Query(ctx context.Context, q interface{}, variables map[string]interface{
```

Currently, there are 3 option types:

- `operation_name`
- `operation_directive`
- `bind_extensions`
Expand Down Expand Up @@ -887,42 +888,70 @@ func (c *Client) NamedQueryRaw(ctx context.Context, name string, q interface{},
func (c *Client) NamedMutateRaw(ctx context.Context, name string, q interface{}, variables map[string]interface{}) ([]byte, error)
```

### Multiple mutations with ordered map
### Dynamic query builder

You might need to make multiple mutations in a single query. It's not very convenient with structs
so you can use ordered map `[][2]interface{}` instead.
You might need to dynamically multiple queries or mutations in a request. It isn't convenient with static structures.
`Builder` helps us construct many queries flexibly.

For example, to make the following GraphQL mutation:

```GraphQL
mutation($login1: String!, $login2: String!, $login3: String!) {
createUser(login: $login1) { login }
createUser(login: $login2) { login }
createUser(login: $login3) { login }
}
variables {
"login1": "grihabor",
"login2": "diman",
"login3": "indigo"
query($userId: String!, $disabled: Boolean!, $limit: Int!) {
userByPk(userId: $userId) { id name }
groups(disabled: $disabled) { id user_permissions }
topUsers: users(limit: $limit) { id name }
}

# variables {
# "userId": "1",
# "disabled": false,
# "limit": 5
# }
```

You can define:

```Go
type CreateUser struct {
Login string
type User struct {
ID string
Name string
}
m := [][2]interface{}{
{"createUser(login: $login1)", &CreateUser{}},
{"createUser(login: $login2)", &CreateUser{}},
{"createUser(login: $login3)", &CreateUser{}},

var groups []struct {
ID string
Permissions []string `graphql:"user_permissions"`
}
variables := map[string]interface{}{
"login1": "grihabor",
"login2": "diman",
"login3": "indigo",

var userByPk User
var topUsers []User

builder := graphql.NewBuilder().
Query("userByPk(userId: $userId)", &userByPk).
Query("groups(disabled: $disabled)", &groups).
Query("topUsers: users(limit: $limit)", &topUsers).
Variables(map[string]interface{}{
"userId": 1,
"disabled": false,
"limit": 5,
})

query, variables, err := builder.Build()
if err != nil {
return err
}

err = client.Query(context.Background(), query, variables)
if err != nil {
return err
}

// or use Query / Mutate shortcut methods
err = builder.Query(client)
if err != nil {
return err
}


```

### Debugging and Unit test
Expand Down Expand Up @@ -982,11 +1011,11 @@ Because the GraphQL query string is generated in runtime using reflection, it is

## Directories

| Path | Synopsis |
| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| [example/graphqldev](https://godoc.org/github.com/shurcooL/graphql/example/graphqldev) | graphqldev is a test program currently being used for developing graphql package. |
| Path | Synopsis |
| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| [example/graphqldev](https://godoc.org/github.com/shurcooL/graphql/example/graphqldev) | graphqldev is a test program currently being used for developing graphql package. |
| [ident](https://godoc.org/github.com/shurcooL/graphql/ident) | Package ident provides functions for parsing and converting identifier names between various naming conventions. |
| [internal/jsonutil](https://godoc.org/github.com/shurcooL/graphql/internal/jsonutil) | Package jsonutil provides a function for decoding JSON into a GraphQL query data structure. |
| [internal/jsonutil](https://godoc.org/github.com/shurcooL/graphql/internal/jsonutil) | Package jsonutil provides a function for decoding JSON into a GraphQL query data structure. |

## References

Expand Down
240 changes: 240 additions & 0 deletions builder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package graphql

import (
"context"
"errors"
"fmt"
"regexp"
)

var (
// regular expression for the graphql variable name
// reference: https://spec.graphql.org/June2018/#sec-Names
regexVariableName = regexp.MustCompile(`\$([_A-Za-z][_0-9A-Za-z]*)`)

errBuildQueryRequired = errors.New("no graphql query to be built")
)

type queryBuilderItem struct {
query string
binding interface{}
requiredVars []string
}

// Builder is used to efficiently build dynamic queries and variables
// It helps construct multiple queries to a single request that needs to be conditionally added
type Builder struct {
context context.Context
queries []queryBuilderItem
variables map[string]interface{}
}

// QueryBinding the type alias of interface tuple
// that includes the query string without fields and the binding type
type QueryBinding [2]interface{}

// NewBuilder creates an empty Builder instance
func NewBuilder() Builder {
return Builder{
variables: make(map[string]interface{}),
}
}

// Bind returns the new Builder with the inputted query
func (b Builder) Context(ctx context.Context) Builder {
return Builder{
context: ctx,
queries: b.queries,
variables: b.variables,
}
}

// Bind returns the new Builder with a new query and target data binding
func (b Builder) Bind(query string, binding interface{}) Builder {
return Builder{
context: b.context,
queries: append(b.queries, queryBuilderItem{
query,
binding,
findAllVariableNames(query),
}),
variables: b.variables,
}
}

// Variables returns the new Builder with the inputted variables
func (b Builder) Variable(key string, value interface{}) Builder {
return Builder{
context: b.context,
queries: b.queries,
variables: setMapValue(b.variables, key, value),
}
}

// Variables returns the new Builder with the inputted variables
func (b Builder) Variables(variables map[string]interface{}) Builder {
return Builder{
context: b.context,
queries: b.queries,
variables: mergeMap(b.variables, variables),
}
}

// Unbind returns the new Builder with query items and related variables removed
func (b Builder) Unbind(query string, extra ...string) Builder {
var newQueries []queryBuilderItem
newVars := make(map[string]interface{})

for _, q := range b.queries {
if q.query == query || sliceStringContains(extra, q.query) {
continue
}
newQueries = append(newQueries, q)
if len(b.variables) > 0 {
for _, k := range q.requiredVars {
if v, ok := b.variables[k]; ok {
newVars[k] = v
}
}
}
}

return Builder{
context: b.context,
queries: newQueries,
variables: newVars,
}
}

// RemoveQuery returns the new Builder with query items removed
// this method only remove query items only,
// to remove both query and variables, use Remove instead
func (b Builder) RemoveQuery(query string, extra ...string) Builder {
var newQueries []queryBuilderItem

for _, q := range b.queries {
if q.query != query && !sliceStringContains(extra, q.query) {
newQueries = append(newQueries, q)
}
}

return Builder{
context: b.context,
queries: newQueries,
variables: b.variables,
}
}

// RemoveQuery returns the new Builder with variable fields removed
func (b Builder) RemoveVariable(key string, extra ...string) Builder {
newVars := make(map[string]interface{})
for k, v := range b.variables {
if k != key && !sliceStringContains(extra, k) {
newVars[k] = v
}
}

return Builder{
context: b.context,
queries: b.queries,
variables: newVars,
}
}

// Build query and variable interfaces
func (b Builder) Build() ([]QueryBinding, map[string]interface{}, error) {
if len(b.queries) == 0 {
return nil, nil, errBuildQueryRequired
}

var requiredVars []string
for _, q := range b.queries {
requiredVars = append(requiredVars, q.requiredVars...)
}
variableLength := len(b.variables)
requiredVariableLength := len(requiredVars)
isMismatchedVariables := variableLength != requiredVariableLength
if !isMismatchedVariables && requiredVariableLength > 0 {
for _, varName := range requiredVars {
if _, ok := b.variables[varName]; !ok {
isMismatchedVariables = true
break
}
}
}
if isMismatchedVariables {
varNames := make([]string, 0, variableLength)
for k := range b.variables {
varNames = append(varNames, k)
}
return nil, nil, fmt.Errorf("mismatched variables; want: %+v; got: %+v", requiredVars, varNames)
}

query := make([]QueryBinding, 0, len(b.queries))
for _, q := range b.queries {
query = append(query, [2]interface{}{q.query, q.binding})
}
return query, b.variables, nil
}

// Query builds parameters and executes the GraphQL query request
func (b Builder) Query(c *Client, options ...Option) error {
q, v, err := b.Build()
if err != nil {
return err
}
ctx := b.context
if ctx == nil {
ctx = context.TODO()
}
return c.Query(ctx, &q, v, options...)
}

// Mutate builds parameters and executes the GraphQL query request
func (b Builder) Mutate(c *Client, options ...Option) error {
q, v, err := b.Build()
if err != nil {
return err
}
ctx := b.context
if ctx == nil {
ctx = context.TODO()
}
return c.Mutate(ctx, &q, v, options...)
}

func setMapValue(src map[string]interface{}, key string, value interface{}) map[string]interface{} {
if src == nil {
src = make(map[string]interface{})
}
src[key] = value
return src
}

func mergeMap(src map[string]interface{}, dest map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{})
for k, v := range src {
setMapValue(result, k, v)
}
for k, v := range dest {
setMapValue(result, k, v)
}
return result
}

func sliceStringContains(slice []string, val string) bool {
for _, s := range slice {
if s == val {
return true
}
}
return false
}

func findAllVariableNames(query string) []string {
var results []string
for _, names := range regexVariableName.FindAllStringSubmatch(query, -1) {
results = append(results, names[1])
}
return results
}
Loading