From 1946d5b24b56939af8e2f09d77459c0cd4e38958 Mon Sep 17 00:00:00 2001 From: Toan Nguyen Date: Sat, 8 Apr 2023 21:39:51 +0700 Subject: [PATCH 1/3] feat: introduce QueryBuilder --- README.md | 66 ++++--- builder.go | 197 +++++++++++++++++++++ builder_test.go | 327 +++++++++++++++++++++++++++++++++++ pkg/jsonutil/graphql.go | 3 + pkg/jsonutil/graphql_test.go | 92 ++++++++++ 5 files changed, 662 insertions(+), 23 deletions(-) create mode 100644 builder.go create mode 100644 builder_test.go diff --git a/README.md b/README.md index ffff602..c6a9877 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ For more information, see package [`github.com/shurcooL/githubv4`](https://githu - [Execute pre-built query](#execute-pre-built-query) - [With operation name (deprecated)](#with-operation-name-deprecated) - [Raw bytes response](#raw-bytes-response) - - [Multiple mutations with ordered map](#multiple-mutations-with-ordered-map) + - [Dynamic query builder](#dynamic-query-builder) - [Debugging and Unit test](#debugging-and-unit-test) - [Directories](#directories) - [References](#references) @@ -830,41 +830,61 @@ 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 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 single request. It's not very convenient with static structures. +`QueryBuilder` 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 + +query, variables, err := graphql.NewQueryBuilder(). + 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, + }) + Build() + +if err != nil { + return err +} + +err = client.Query(context.Background(), query, variables) +if err != nil { + return err } ``` diff --git a/builder.go b/builder.go new file mode 100644 index 0000000..bba0961 --- /dev/null +++ b/builder.go @@ -0,0 +1,197 @@ +package graphql + +import ( + "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 +} + +// QueryBuilder is used to efficiently build dynamic queries and variables +// It helps construct multiple queries to a single request that need to be conditionally added +type QueryBuilder struct { + 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{} + +// NewQueryBuilder creates an empty QueryBuilder instance +func NewQueryBuilder() QueryBuilder { + return QueryBuilder{ + variables: make(map[string]interface{}), + } +} + +// Query returns the new QueryBuilder with the inputted query +func (b QueryBuilder) Query(query string, binding interface{}) QueryBuilder { + return QueryBuilder{ + queries: append(b.queries, queryBuilderItem{ + query, + binding, + findAllVariableNames(query), + }), + variables: b.variables, + } +} + +// Variables returns the new QueryBuilder with the inputted variables +func (b QueryBuilder) Variable(key string, value interface{}) QueryBuilder { + return QueryBuilder{ + queries: b.queries, + variables: setMapValue(b.variables, key, value), + } +} + +// Variables returns the new QueryBuilder with the inputted variables +func (b QueryBuilder) Variables(variables map[string]interface{}) QueryBuilder { + return QueryBuilder{ + queries: b.queries, + variables: mergeMap(b.variables, variables), + } +} + +// RemoveQuery returns the new QueryBuilder with query items and related variables removed +func (b QueryBuilder) Remove(query string, extra ...string) QueryBuilder { + 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 QueryBuilder{ + queries: newQueries, + variables: newVars, + } +} + +// RemoveQuery returns the new QueryBuilder with query items removed +// this method only remove query items only, +// to remove both query and variables, use Remove instead +func (b QueryBuilder) RemoveQuery(query string, extra ...string) QueryBuilder { + var newQueries []queryBuilderItem + + for _, q := range b.queries { + if q.query != query && !sliceStringContains(extra, q.query) { + newQueries = append(newQueries, q) + } + } + + return QueryBuilder{ + queries: newQueries, + variables: b.variables, + } +} + +// RemoveQuery returns the new QueryBuilder with variable fields removed +func (b QueryBuilder) RemoveVariable(key string, extra ...string) QueryBuilder { + newVars := make(map[string]interface{}) + for k, v := range b.variables { + if k != key && !sliceStringContains(extra, k) { + newVars[k] = v + } + } + + return QueryBuilder{ + queries: b.queries, + variables: newVars, + } +} + +// Build query and variable interfaces +func (b QueryBuilder) 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 +} + +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 +} diff --git a/builder_test.go b/builder_test.go new file mode 100644 index 0000000..fda337e --- /dev/null +++ b/builder_test.go @@ -0,0 +1,327 @@ +package graphql_test + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/hasura/go-graphql-client" +) + +func TestQueryBuilder(t *testing.T) { + + queryStruct1 := func() interface{} { + m := struct { + ID string + Name string + }{} + return &m + } + + queryStruct2 := func() interface{} { + m := struct { + ID string + Email string + Addresses []struct { + Street string + City string + } + }{} + return &m + } + + queryStruct3 := func() interface{} { + m := struct { + ID string + Email string + Points []string + }{} + return &m + } + + fixtures := []struct { + queries map[string]interface{} + variables map[string]interface{} + variableMismatched bool + err error + }{ + { + queries: map[string]interface{}{ + "person": queryStruct1(), + "personAddress": queryStruct2(), + "personPoint": queryStruct3(), + }, + }, + { + queries: map[string]interface{}{ + "person(id: $id)": queryStruct1(), + "personAddress(id: $addressId)": queryStruct2(), + "personPoint(where: $where)": queryStruct3(), + }, + variables: map[string]interface{}{ + "id": nil, + "addressId": nil, + "where": nil, + }, + }, + { + err: errors.New("no graphql query to be built"), + }, + { + queries: map[string]interface{}{ + "person(id: $id)": queryStruct1(), + "personAddress(id: $addressId)": queryStruct2(), + "personPoint(where: $where)": queryStruct3(), + }, + variables: map[string]interface{}{ + "id": nil, + "addressId": nil, + }, + variableMismatched: true, + }, + } + + for i, f := range fixtures { + builder := graphql.QueryBuilder{} + for q, b := range f.queries { + builder = builder.Query(q, b) + } + for k, v := range f.variables { + builder = builder.Variable(k, v) + } + + queries, vars, err := builder.Build() + if f.variableMismatched && err != nil { + if !strings.Contains(err.Error(), "mismatched variables;") { + t.Errorf("[%d] got: %+v, want mismatched variables error", i, err) + } + } else if testEqualError(t, f.err, err, fmt.Sprintf("[%d]", i)) && f.err == nil && err == nil { + testEqualMap(t, f.variables, vars, fmt.Sprintf("[%d]", i)) + queryFailed := len(queries) != len(f.queries) + if !queryFailed { + for _, q := range queries { + wantQuery, ok := f.queries[q[0].(string)] + if !ok || wantQuery != q[1] { + queryFailed = true + break + } + } + } + if queryFailed { + t.Errorf("[%d] queries mismatched. got: %+v, want: %+v", i, queries, f.queries) + } + } + } +} + +func TestQueryBuilder_remove(t *testing.T) { + + var queryStruct1 struct { + ID string + Name string + } + + var queryStruct2 struct { + ID string + Email string + Addresses []struct { + Street string + City string + } + } + + var queryStruct3 struct { + ID string + Email string + Points []string + } + + fixture := struct { + queries map[string]interface{} + variables map[string]interface{} + }{ + queries: map[string]interface{}{ + "person(id: $id)": queryStruct1, + "personAddress(id: $addressId)": queryStruct2, + "personPoint(where: $where)": queryStruct3, + }, + variables: map[string]interface{}{ + "id": nil, + "addressId": nil, + "where": nil, + }, + } + + builder := graphql.QueryBuilder{} + for q, b := range fixture.queries { + builder = builder.Query(q, b) + } + for k, v := range fixture.variables { + builder = builder.Variable(k, v) + } + + builder = builder.RemoveQuery("person(id: $id)") + _, _, e1 := builder.Build() + + if e1 == nil || !strings.Contains(e1.Error(), "mismatched variables;") { + t.Errorf("remove query failed, got: nil, want mismatched error") + } + + builder = builder.RemoveVariable("id") + q2, v2, e2 := builder.Build() + + if e2 != nil { + t.Errorf("remove query failed, got: %+v, want nil error", e2) + } + + expected2 := [][2]interface{}{ + {"personAddress(id: $addressId)", queryStruct2}, + {"personPoint(where: $where)", queryStruct3}, + } + if len(q2) != 2 { + t.Errorf("remove query failed, got: %+v, want %+v", q2, expected2) + } + + testEqualMap(t, v2, map[string]interface{}{ + "addressId": nil, + "where": nil, + }, "remove query failed") + + builder = builder.Remove("personPoint(where: $where)") + q3, v3, e3 := builder.Build() + + if e3 != nil { + t.Errorf("remove query failed, got: %+v, want nil error", e3) + } + + if len(q3) != 1 || q3[0][0] != "personAddress(id: $addressId)" { + t.Errorf("remove query failed, got: %+v, want %+v", q2, expected2) + } + + testEqualMap(t, v3, map[string]interface{}{ + "addressId": nil, + }, "remove query failed") + +} + +// Test query QueryBuilder output +func TestQueryBuilder_Query(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) { + body := mustRead(req.Body) + if got, want := body, `{"query":"query ($id:String!){user(id: $id){id,name}}","variables":{"id":"1"}}`+"\n"; got != want { + t.Errorf("got body: %v, want %v", got, want) + } + w.Header().Set("Content-Type", "application/json") + mustWrite(w, `{"data": {"user": {"id": "1", "name": "Gopher"}}}`) + }) + client := graphql.NewClient("/graphql", &http.Client{Transport: localRoundTripper{handler: mux}}) + + var user struct { + ID string `graphql:"id"` + Name string `graphql:"name"` + } + + bq, vars, err := graphql.NewQueryBuilder().Query("user(id: $id)", &user).Variable("id", "1").Build() + if err != nil { + t.Fatal(err) + } + + err = client.Query(context.Background(), &bq, vars) + if err != nil { + t.Fatal(err) + } + + if got, want := user.Name, "Gopher"; got != want { + t.Errorf("got user.Name: %q, want: %q", got, want) + } + if got, want := user.ID, "1"; got != want { + t.Errorf("got user.ID: %q, want: %q", got, want) + } +} + +// Test query QueryBuilder output with multiple queries +func TestQueryBuilder_MultipleQueries(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) { + body := mustRead(req.Body) + if got, want := body, `{"query":"{user{id,name}person{id,email,points}}"}`+"\n"; got != want { + t.Errorf("got body: %v, want %v", got, want) + } + w.Header().Set("Content-Type", "application/json") + mustWrite(w, `{"data": {"user": {"id": "1", "name": "Gopher"},"person": [{"id": "2", "email": "gopher@domain", "points": ["1", "2"]}]}}`) + }) + client := graphql.NewClient("/graphql", &http.Client{Transport: localRoundTripper{handler: mux}}) + + var user struct { + ID string `graphql:"id"` + Name string `graphql:"name"` + } + + q2 := make([]struct { + ID string + Email string + Points []string + }, 0) + + bq, vars, err := graphql.NewQueryBuilder().Query("user", &user).Query("person", &q2).Build() + if err != nil { + t.Fatal(err) + } + + err = client.Query(context.Background(), &bq, vars) + if err != nil { + t.Fatal(err) + } + + if got, want := user.Name, "Gopher"; got != want { + t.Errorf("got user.Name: %q, want: %q", got, want) + } + if got, want := user.ID, "1"; got != want { + t.Errorf("got user.ID: %q, want: %q", got, want) + } + if got, want := q2[0].ID, "2"; got != want { + t.Errorf("got q2.ID: %q, want: %q", got, want) + } + if got, want := q2[0].Email, "gopher@domain"; got != want { + t.Errorf("got q2.Email: %q, want: %q", got, want) + } + if got, want := q2[0].Points, "[1 2]"; fmt.Sprint(got) != want { + t.Errorf("got q2.Points: %q, want: %q", got, want) + } +} + +func testEqualError(t *testing.T, want error, got error, msg string) bool { + if (got == nil && want == nil) || (got != nil && want != nil && got.Error() == want.Error()) { + return true + } + if msg != "" { + msg = msg + " " + } + + t.Errorf("%sgot: %+v, want: %+v", msg, got, want) + return false +} + +func testEqualMap(t *testing.T, want map[string]interface{}, got map[string]interface{}, msg string) { + failed := len(want) != len(got) + if !failed && len(want) > 0 { + for key, val := range want { + v, ok := got[key] + if !ok || v != val { + failed = true + break + } + } + } + + if failed { + if msg != "" { + msg = msg + " " + } + t.Errorf("%sgot: %+v, want: %+v", msg, got, want) + } +} diff --git a/pkg/jsonutil/graphql.go b/pkg/jsonutil/graphql.go index 69546fb..1b9dce1 100644 --- a/pkg/jsonutil/graphql.go +++ b/pkg/jsonutil/graphql.go @@ -358,6 +358,9 @@ func (d *decoder) popAllVs() { func (d *decoder) popLeftArrayTemplates() { for i := range d.vs { v := d.vs[i].Top() + for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { + v = v.Elem() + } if v.IsValid() { v.Set(v.Slice(1, v.Len())) } diff --git a/pkg/jsonutil/graphql_test.go b/pkg/jsonutil/graphql_test.go index 9b48714..9c3ef84 100644 --- a/pkg/jsonutil/graphql_test.go +++ b/pkg/jsonutil/graphql_test.go @@ -2,6 +2,7 @@ package jsonutil_test import ( "encoding/json" + "fmt" "reflect" "testing" "time" @@ -159,6 +160,97 @@ func TestUnmarshalGraphQL_orderedMap(t *testing.T) { } } +func TestUnmarshalGraphQL_orderedSliceMap(t *testing.T) { + type query [][2]interface{} + var result []string + got := query{ + {"foo", &result}, + } + err := jsonutil.UnmarshalGraphQL([]byte(`{ + "foo": ["bar", "baz"] + }`), &got) + if err != nil { + t.Fatal(err) + } + want := query{ + {"foo", &result}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("not equal: %v != %v", got, want) + } + if g, w := fmt.Sprint(result), fmt.Sprint([]string{"bar", "baz"}); g != w { + t.Errorf("not equal: %v != %v", g, w) + } +} + +func TestUnmarshalGraphQL_orderedSliceMapMultipleBindings(t *testing.T) { + + type query [][2]interface{} + var resultObject struct { + ID int + Name string + } + var resultSlice []struct { + Email string + Active bool + Roles []struct { + Name string + } + } + + got := query{ + {"user", &resultObject}, + {"profiles", &resultSlice}, + } + err := jsonutil.UnmarshalGraphQL([]byte(`{ + "user": {"id":1,"name":"Gopher"}, + "profiles": [ + {"email":"gopher@domain","active": true, "roles": [{"name":"admin"},{"name":"user"}]}, + {"email":"gopher2@domain","active": false, "roles": [{"name":"anonymous"}]} + ] + }`), &got) + if err != nil { + t.Fatal(err) + } + want := query{ + {"user", &resultObject}, + {"profiles", &resultSlice}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("not equal: %v != %v", got, want) + } + if g, w := resultObject.ID, 1; g != w { + t.Errorf("not equal: %v != %v", g, w) + } + if g, w := resultObject.Name, "Gopher"; g != w { + t.Errorf("not equal: %v != %v", g, w) + } + if g, w := len(resultSlice), 2; g != w { + t.Errorf("not equal: %v != %v", g, w) + } + if g, w := resultSlice[0].Email, "gopher@domain"; g != w { + t.Errorf("not equal: %v != %v", g, w) + } + if g, w := resultSlice[0].Active, true; g != w { + t.Errorf("not equal: %v != %v", g, w) + } + if g, w := resultSlice[0].Roles[0].Name, "admin"; g != w { + t.Errorf("not equal: %v != %v", g, w) + } + if g, w := resultSlice[0].Roles[1].Name, "user"; g != w { + t.Errorf("not equal: %v != %v", g, w) + } + if g, w := resultSlice[1].Email, "gopher2@domain"; g != w { + t.Errorf("not equal: %v != %v", g, w) + } + if g, w := resultSlice[1].Active, false; g != w { + t.Errorf("not equal: %v != %v", g, w) + } + if g, w := resultSlice[1].Roles[0].Name, "anonymous"; g != w { + t.Errorf("not equal: %v != %v", g, w) + } +} + func TestUnmarshalGraphQL_orderedMapAlias(t *testing.T) { type Update struct { Name string `graphql:"name"` From 7bbef7a1abebda8cd38474bbf15d23bda4830fc2 Mon Sep 17 00:00:00 2001 From: Toan Nguyen Date: Sat, 8 Apr 2023 21:47:56 +0700 Subject: [PATCH 2/3] fix typo --- README.md | 2 +- builder.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c6a9877..1e4d25d 100644 --- a/README.md +++ b/README.md @@ -832,7 +832,7 @@ func (c *Client) NamedMutateRaw(ctx context.Context, name string, q interface{}, ### Dynamic query builder -You might need to dynamically multiple queries or mutations in a single request. It's not very convenient with static structures. +You might need to dynamically multiple queries or mutations in a request. It isn't convenient with static structures. `QueryBuilder` helps us construct many queries flexibly. For example, to make the following GraphQL mutation: diff --git a/builder.go b/builder.go index bba0961..455f478 100644 --- a/builder.go +++ b/builder.go @@ -21,7 +21,7 @@ type queryBuilderItem struct { } // QueryBuilder is used to efficiently build dynamic queries and variables -// It helps construct multiple queries to a single request that need to be conditionally added +// It helps construct multiple queries to a single request that needs to be conditionally added type QueryBuilder struct { queries []queryBuilderItem variables map[string]interface{} From 3e9dc3a60c794da6781d83644dd083b42ba4ca5a Mon Sep 17 00:00:00 2001 From: Toan Nguyen Date: Sun, 16 Apr 2023 00:26:53 +0700 Subject: [PATCH 3/3] add Query and Mutate shortcuts --- README.md | 14 ++++++-- builder.go | 91 ++++++++++++++++++++++++++++++++++++------------- builder_test.go | 66 ++++++++++++++++++++++++----------- 3 files changed, 124 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 1e4d25d..ed9086c 100644 --- a/README.md +++ b/README.md @@ -833,7 +833,7 @@ func (c *Client) NamedMutateRaw(ctx context.Context, name string, q interface{}, ### Dynamic query builder You might need to dynamically multiple queries or mutations in a request. It isn't convenient with static structures. -`QueryBuilder` helps us construct many queries flexibly. +`Builder` helps us construct many queries flexibly. For example, to make the following GraphQL mutation: @@ -867,7 +867,7 @@ var groups []struct { var userByPk User var topUsers []User -query, variables, err := graphql.NewQueryBuilder(). +builder := graphql.NewBuilder(). Query("userByPk(userId: $userId)", &userByPk). Query("groups(disabled: $disabled)", &groups). Query("topUsers: users(limit: $limit)", &topUsers). @@ -876,8 +876,8 @@ query, variables, err := graphql.NewQueryBuilder(). "disabled": false, "limit": 5, }) - Build() +query, variables, err := builder.Build() if err != nil { return err } @@ -886,6 +886,14 @@ 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 diff --git a/builder.go b/builder.go index 455f478..7796c8d 100644 --- a/builder.go +++ b/builder.go @@ -1,6 +1,7 @@ package graphql import ( + "context" "errors" "fmt" "regexp" @@ -20,9 +21,10 @@ type queryBuilderItem struct { requiredVars []string } -// QueryBuilder is used to efficiently build dynamic queries and variables +// 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 QueryBuilder struct { +type Builder struct { + context context.Context queries []queryBuilderItem variables map[string]interface{} } @@ -31,16 +33,26 @@ type QueryBuilder struct { // that includes the query string without fields and the binding type type QueryBinding [2]interface{} -// NewQueryBuilder creates an empty QueryBuilder instance -func NewQueryBuilder() QueryBuilder { - return QueryBuilder{ +// NewBuilder creates an empty Builder instance +func NewBuilder() Builder { + return Builder{ variables: make(map[string]interface{}), } } -// Query returns the new QueryBuilder with the inputted query -func (b QueryBuilder) Query(query string, binding interface{}) QueryBuilder { - return QueryBuilder{ +// 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, @@ -50,24 +62,26 @@ func (b QueryBuilder) Query(query string, binding interface{}) QueryBuilder { } } -// Variables returns the new QueryBuilder with the inputted variables -func (b QueryBuilder) Variable(key string, value interface{}) QueryBuilder { - return QueryBuilder{ +// 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 QueryBuilder with the inputted variables -func (b QueryBuilder) Variables(variables map[string]interface{}) QueryBuilder { - return QueryBuilder{ +// 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), } } -// RemoveQuery returns the new QueryBuilder with query items and related variables removed -func (b QueryBuilder) Remove(query string, extra ...string) QueryBuilder { +// 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{}) @@ -85,16 +99,17 @@ func (b QueryBuilder) Remove(query string, extra ...string) QueryBuilder { } } - return QueryBuilder{ + return Builder{ + context: b.context, queries: newQueries, variables: newVars, } } -// RemoveQuery returns the new QueryBuilder with query items removed +// 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 QueryBuilder) RemoveQuery(query string, extra ...string) QueryBuilder { +func (b Builder) RemoveQuery(query string, extra ...string) Builder { var newQueries []queryBuilderItem for _, q := range b.queries { @@ -103,14 +118,15 @@ func (b QueryBuilder) RemoveQuery(query string, extra ...string) QueryBuilder { } } - return QueryBuilder{ + return Builder{ + context: b.context, queries: newQueries, variables: b.variables, } } -// RemoveQuery returns the new QueryBuilder with variable fields removed -func (b QueryBuilder) RemoveVariable(key string, extra ...string) QueryBuilder { +// 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) { @@ -118,14 +134,15 @@ func (b QueryBuilder) RemoveVariable(key string, extra ...string) QueryBuilder { } } - return QueryBuilder{ + return Builder{ + context: b.context, queries: b.queries, variables: newVars, } } // Build query and variable interfaces -func (b QueryBuilder) Build() ([]QueryBinding, map[string]interface{}, error) { +func (b Builder) Build() ([]QueryBinding, map[string]interface{}, error) { if len(b.queries) == 0 { return nil, nil, errBuildQueryRequired } @@ -160,6 +177,32 @@ func (b QueryBuilder) Build() ([]QueryBinding, map[string]interface{}, error) { 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{}) diff --git a/builder_test.go b/builder_test.go index fda337e..633fd02 100644 --- a/builder_test.go +++ b/builder_test.go @@ -1,7 +1,6 @@ package graphql_test import ( - "context" "errors" "fmt" "net/http" @@ -11,7 +10,7 @@ import ( "github.com/hasura/go-graphql-client" ) -func TestQueryBuilder(t *testing.T) { +func TestBuilder(t *testing.T) { queryStruct1 := func() interface{} { m := struct { @@ -85,9 +84,9 @@ func TestQueryBuilder(t *testing.T) { } for i, f := range fixtures { - builder := graphql.QueryBuilder{} + builder := graphql.Builder{} for q, b := range f.queries { - builder = builder.Query(q, b) + builder = builder.Bind(q, b) } for k, v := range f.variables { builder = builder.Variable(k, v) @@ -117,7 +116,7 @@ func TestQueryBuilder(t *testing.T) { } } -func TestQueryBuilder_remove(t *testing.T) { +func TestBuilder_remove(t *testing.T) { var queryStruct1 struct { ID string @@ -155,9 +154,9 @@ func TestQueryBuilder_remove(t *testing.T) { }, } - builder := graphql.QueryBuilder{} + builder := graphql.Builder{} for q, b := range fixture.queries { - builder = builder.Query(q, b) + builder = builder.Bind(q, b) } for k, v := range fixture.variables { builder = builder.Variable(k, v) @@ -190,7 +189,7 @@ func TestQueryBuilder_remove(t *testing.T) { "where": nil, }, "remove query failed") - builder = builder.Remove("personPoint(where: $where)") + builder = builder.Unbind("personPoint(where: $where)") q3, v3, e3 := builder.Build() if e3 != nil { @@ -207,8 +206,8 @@ func TestQueryBuilder_remove(t *testing.T) { } -// Test query QueryBuilder output -func TestQueryBuilder_Query(t *testing.T) { +// Test Builder query output +func TestBuilder_Query(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) { body := mustRead(req.Body) @@ -225,12 +224,44 @@ func TestQueryBuilder_Query(t *testing.T) { Name string `graphql:"name"` } - bq, vars, err := graphql.NewQueryBuilder().Query("user(id: $id)", &user).Variable("id", "1").Build() + err := graphql.NewBuilder(). + Bind("user(id: $id)", &user). + Variable("id", "1"). + Query(client) if err != nil { t.Fatal(err) } - err = client.Query(context.Background(), &bq, vars) + if got, want := user.Name, "Gopher"; got != want { + t.Errorf("got user.Name: %q, want: %q", got, want) + } + if got, want := user.ID, "1"; got != want { + t.Errorf("got user.ID: %q, want: %q", got, want) + } +} + +// Test Builder mutation output +func TestBuilder_Mutate(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) { + body := mustRead(req.Body) + if got, want := body, `{"query":"mutation ($id:String!){user(id: $id){id,name}}","variables":{"id":"1"}}`+"\n"; got != want { + t.Errorf("got body: %v, want %v", got, want) + } + w.Header().Set("Content-Type", "application/json") + mustWrite(w, `{"data": {"user": {"id": "1", "name": "Gopher"}}}`) + }) + client := graphql.NewClient("/graphql", &http.Client{Transport: localRoundTripper{handler: mux}}) + + var user struct { + ID string `graphql:"id"` + Name string `graphql:"name"` + } + + err := graphql.NewBuilder(). + Bind("user(id: $id)", &user). + Variable("id", "1"). + Mutate(client) if err != nil { t.Fatal(err) } @@ -243,8 +274,8 @@ func TestQueryBuilder_Query(t *testing.T) { } } -// Test query QueryBuilder output with multiple queries -func TestQueryBuilder_MultipleQueries(t *testing.T) { +// Test query Builder output with multiple queries +func TestBuilder_MultipleQueries(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) { body := mustRead(req.Body) @@ -267,12 +298,7 @@ func TestQueryBuilder_MultipleQueries(t *testing.T) { Points []string }, 0) - bq, vars, err := graphql.NewQueryBuilder().Query("user", &user).Query("person", &q2).Build() - if err != nil { - t.Fatal(err) - } - - err = client.Query(context.Background(), &bq, vars) + err := graphql.NewBuilder().Bind("user", &user).Bind("person", &q2).Query(client) if err != nil { t.Fatal(err) }