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

Document and improve support for binding non-scalars to a specific type #69

Merged
merged 3 commits into from
Aug 27, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions example/genqlient.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ operations:
- genqlient.graphql
generated: generated.go

# We map github's DateTime type to Go's time.Time (which conveniently already
# defines MarshalJSON and UnmarshalJSON).
scalars:
DateTime: time.Time
# We bind github's DateTime scalar type to Go's time.Time (which conveniently
# already defines MarshalJSON and UnmarshalJSON). This means genqlient will
# use time.Time when a query requests a DateTime, and is required for custom
# scalars.
bindings:
DateTime:
type: time.Time
36 changes: 29 additions & 7 deletions generate/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,25 @@ type Config struct {
// TODO(#5): This is a bit broken, fix it.
ClientGetter string `yaml:"client_getter"`

// A map from GraphQL scalar type name to Go fully-qualified type name for
// the types to use for any custom or builtin scalars. By default, builtin
// scalars are mapped to the obvious Go types (String and ID to string, Int
// to int, Float to float64, and Boolean to bool), but this setting will
// extend or override those mappings. These types must define MarshalJSON
// and UnmarshalJSON methods, or otherwise be convertible to JSON.
Scalars map[string]string `yaml:"scalars"`
// A map from GraphQL type name to Go fully-qualified type name to override
// the Go type genqlient will use for this GraphQL type.
//
// This is primarily used for custom scalars, or to map builtin scalars to
// a nonstandard type. By default, builtin scalars are mapped to the
// obvious Go types (String and ID to string, Int to int, Float to float64,
// and Boolean to bool), but this setting will extend or override those
// mappings.
//
// genqlient does not validate these types in any way; they must define
// whatever logic is needed (MarshalJSON/UnmarshalJSON or JSON tags) to
// convert to/from JSON. For this reason, it's not recommended to use this
// setting to map object, interface, or union types, because nothing
// guarantees that the fields requested in the query match those present in
// the Go type.
//
// To get equivalent behavior in just one query, use @genqlient(bind: ...);
// see GenqlientDirective.Bind for more details.
Bindings map[string]*TypeBinding `yaml:"bindings"`

// Set to true to use features that aren't fully ready to use.
//
Expand All @@ -84,6 +96,16 @@ type Config struct {
configFilename string
}

// A TypeBinding represents a Go type to which genqlient will bind a particular
// GraphQL type. See Config.Bind, above, for more details.
type TypeBinding struct {
// The fully-qualified name of the Go type to which to bind. For example:
// time.Time
// map[string]interface{}
// github.com/you/yourpkg/subpkg.MyType
Type string `yaml:"type"`
}

// baseDir returns the directory of the config-file (relative to which
// all the other paths are resolved).
func (c *Config) baseDir() string {
Expand Down
31 changes: 23 additions & 8 deletions generate/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func (g *generator) convertOperation(

goTyp, err := g.convertDefinition(
name, operation.Name, baseType, operation.Position,
operation.SelectionSet, queryOptions)
operation.SelectionSet, queryOptions, queryOptions)

if structType, ok := goTyp.(*goStructType); ok {
// Override the ordinary description; the GraphQL documentation for
Expand Down Expand Up @@ -140,6 +140,15 @@ func (g *generator) convertType(
selectionSet ast.SelectionSet,
options, queryOptions *GenqlientDirective,
) (goType, error) {
// We check for local bindings here, so that you can bind, say, a
// `[String!]` to a struct instead of a slice. Global bindings can only
// bind GraphQL named types, at least for now.
localBinding := options.Bind
if localBinding != "" && localBinding != "-" {
goRef, err := g.addRef(localBinding)
return &goOpaqueType{goRef}, err
}

if typ.Elem != nil {
// Type is a list.
elem, err := g.convertType(
Expand All @@ -150,7 +159,7 @@ func (g *generator) convertType(
// If this is a builtin type or custom scalar, just refer to it.
def := g.schema.Types[typ.Name()]
goTyp, err := g.convertDefinition(
name, namePrefix, def, typ.Position, selectionSet, queryOptions)
name, namePrefix, def, typ.Position, selectionSet, options, queryOptions)

if options.GetPointer() {
// Whatever we get, wrap it in a pointer. (Because of the way the
Expand All @@ -172,11 +181,16 @@ func (g *generator) convertDefinition(
def *ast.Definition,
pos *ast.Position,
selectionSet ast.SelectionSet,
queryOptions *GenqlientDirective,
options, queryOptions *GenqlientDirective,
) (goType, error) {
qualifiedGoName, ok := g.Config.Scalars[def.Name]
if ok {
goRef, err := g.addRef(qualifiedGoName)
// Check if we should use an existing type. (This is usually true for
// GraphQL scalars, but we allow you to bind non-scalar types too, if you
// want, subject to the caveats described in Config.Bindings.) Local
// bindings are checked in the caller (convertType) and never get here,
// unless the binding is "-" which means "ignore the global binding".
globalBinding, ok := g.Config.Bindings[def.Name]
if ok && options.Bind != "-" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may want to comment that the options.Bind check is to allow a local option to override applying the global option.

goRef, err := g.addRef(globalBinding.Type)
return &goOpaqueType{goRef}, err
}
goBuiltinName, ok := builtinTypes[def.Name]
Expand Down Expand Up @@ -306,7 +320,7 @@ func (g *generator) convertDefinition(
// preprocessQueryDocument). But in practice it doesn't really
// hurt, and would be extra work to avoid, so we just leave it.
implTyp, err := g.convertDefinition(
implName, namePrefix, implDef, pos, selectionSet, queryOptions)
implName, namePrefix, implDef, pos, selectionSet, options, queryOptions)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -335,8 +349,9 @@ func (g *generator) convertDefinition(
return goType, nil

case ast.Scalar:
// (If you had an entry in bindings, we would have returned it above.)
return nil, errorf(
pos, "unknown scalar %v: please add it to genqlient.yaml", def.Name)
pos, `unknown scalar %v: please add it to "bindings" in genqlient.yaml`, def.Name)
default:
return nil, errorf(pos, "unexpected kind: %v", def.Kind)
}
Expand Down
18 changes: 10 additions & 8 deletions generate/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,13 @@ func TestGenerate(t *testing.T) {
Package: "test",
Generated: goFilename,
ExportOperations: queriesFilename,
Scalars: map[string]string{
"ID": "github.com/Khan/genqlient/internal/testutil.ID",
"DateTime": "time.Time",
"Junk": "interface{}",
"ComplexJunk": "[]map[string]*[]*map[string]interface{}",
Bindings: map[string]*TypeBinding{
"ID": {Type: "github.com/Khan/genqlient/internal/testutil.ID"},
"DateTime": {Type: "time.Time"},
"Junk": {Type: "interface{}"},
"ComplexJunk": {Type: "[]map[string]*[]*map[string]interface{}"},
"Pokemon": {Type: "github.com/Khan/genqlient/internal/testutil.Pokemon"},
"PokemonInput": {Type: "github.com/Khan/genqlient/internal/testutil.Pokemon"},
},
AllowBrokenFeatures: true,
})
Expand Down Expand Up @@ -138,9 +140,9 @@ func TestGenerateErrors(t *testing.T) {
Operations: []string{filepath.Join(errorsDir, sourceFilename)},
Package: "test",
Generated: os.DevNull,
Scalars: map[string]string{
"ValidScalar": "string",
"InvalidScalar": "bogus",
Bindings: map[string]*TypeBinding{
"ValidScalar": {Type: "string"},
"InvalidScalar": {Type: "bogus"},
},
AllowBrokenFeatures: true,
})
Expand Down
49 changes: 45 additions & 4 deletions generate/genqlient_directive.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,34 @@ type GenqlientDirective struct {
// pointer to save copies) or if you wish to distinguish between the Go
// zero value and null (for nullable fields).
Pointer *bool

// If set, this argument or field will use the given Go type instead of a
// genqlient-generated type.
//
// The value should be the fully-qualified type name to use for the field,
// for example:
// time.Time
// map[string]interface{}
// []github.com/you/yourpkg/subpkg.MyType
// Note that the type is the type of the whole field, e.g. if your field in
// GraphQL has type `[DateTime]`, you'd do
// # @genqlient(bind: "[]time.Time")
// (But you're not required to; if you want to map to some type DateList,
// you can do that, as long as its UnmarshalJSON method can accept a list
// of datetimes.)
//
// See Config.Bindings for more details; this is effectively to a local
// version of that global setting and should be used with similar care.
// If set to "-", overrides any such global setting and uses a
// genqlient-generated type.
Bind string
}

func (dir *GenqlientDirective) GetOmitempty() bool { return dir.Omitempty != nil && *dir.Omitempty }
func (dir *GenqlientDirective) GetPointer() bool { return dir.Pointer != nil && *dir.Pointer }

func setBool(dst **bool, v *ast.Value) error {
ei, err := v.Value(nil) // no vars allowed
// TODO: here and below, put positions on these errors
if err != nil {
return errorf(v.Position, "invalid boolean value %v: %v", v, err)
}
Expand All @@ -81,6 +101,18 @@ func setBool(dst **bool, v *ast.Value) error {
return errorf(v.Position, "expected boolean, got non-boolean value %T(%v)", ei, ei)
}

func setString(dst *string, v *ast.Value) error {
ei, err := v.Value(nil) // no vars allowed
if err != nil {
return errorf(v.Position, "invalid string value %v: %v", v, err)
}
if b, ok := ei.(string); ok {
*dst = b
return nil
}
return errorf(v.Position, "expected string, got non-string value %T(%v)", ei, ei)
}

func fromGraphQL(dir *ast.Directive) (*GenqlientDirective, error) {
if dir.Name != "genqlient" {
// Actually we just won't get here; we only get here if the line starts
Expand All @@ -99,6 +131,8 @@ func fromGraphQL(dir *ast.Directive) (*GenqlientDirective, error) {
err = setBool(&retval.Omitempty, arg.Value)
case "pointer":
err = setBool(&retval.Pointer, arg.Value)
case "bind":
err = setString(&retval.Bind, arg.Value)
default:
return nil, errorf(arg.Position, "unknown argument %v for @genqlient", arg.Name)
}
Expand All @@ -112,8 +146,12 @@ func fromGraphQL(dir *ast.Directive) (*GenqlientDirective, error) {
func (dir *GenqlientDirective) validate(node interface{}) error {
switch node := node.(type) {
case *ast.OperationDefinition:
// Anything is valid on the entire operation; it will just apply to
// whatever it is relevant to.
if dir.Bind != "" {
return errorf(dir.pos, "bind may not be applied to the entire operation")
}

// Anything else is valid on the entire operation; it will just apply
// to whatever it is relevant to.
return nil
case *ast.VariableDefinition:
if dir.Omitempty != nil && node.Type.NonNull {
Expand All @@ -122,7 +160,7 @@ func (dir *GenqlientDirective) validate(node interface{}) error {
return nil
case *ast.Field:
if dir.Omitempty != nil {
return errorf(dir.pos, "omitempty is not appilcable to fields")
return errorf(dir.pos, "omitempty is not applicable to fields")
}
return nil
default:
Expand All @@ -138,6 +176,9 @@ func (dir *GenqlientDirective) merge(other *GenqlientDirective) *GenqlientDirect
if other.Pointer != nil {
retval.Pointer = other.Pointer
}
if other.Bind != "" {
retval.Bind = other.Bind
}
return &retval
}

Expand Down
16 changes: 16 additions & 0 deletions generate/testdata/queries/Pokemon.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
query GetPokemonSiblings($input: PokemonInput!) {
user(query: {hasPokemon: $input}) {
# this will override the default mapping to internal/testutil.ID:
# @genqlient(bind: "string")
id
# this is normally an enum, but here we make it a (list of) string:
# @genqlient(bind: "[]string")
roles
name
# this is mapped globally to internal/testutil.Pokemon:
pokemon { species level }
# this overrides said mapping:
# @genqlient(bind: "-")
genqlientPokemon: pokemon { species level }
}
}
12 changes: 12 additions & 0 deletions generate/testdata/queries/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ enum Role {
TEACHER
}

input PokemonInput {
species: String!
level: Int!
}

type Pokemon {
species: String!
level: Int!
}

"""UserQueryInput is the argument to Query.users.

Ideally this would support anything and everything!
Expand All @@ -33,6 +43,7 @@ input UserQueryInput {
id: ID
role: Role
names: [String]
hasPokemon: PokemonInput
}

type AuthMethod {
Expand All @@ -53,6 +64,7 @@ type User {
emailsWithNulls: [String]!
emailsWithNullsOrNull: [String]
authMethods: [AuthMethod!]!
pokemon: [Pokemon!]
}

"""Content is implemented by various types like Article, Video, and Topic."""
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading