diff --git a/docs/FAQ.md b/docs/FAQ.md index 0e54a34..4fe8260 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -183,6 +183,28 @@ if novel, ok := resp.Favorite.(*GetBooksFavoriteNovel); ok { The interface-type's GoDoc will include a list of its implementations, for your convenience. +If you only want to request shared fields of the interface (i.e. no fragments), this may seem like a lot of ceremony. If you prefer, you can instead add `# @genqlient(struct: true)` to the field, and genqlient will just generate a struct, like it does for GraphQL object types. For example, given: + +```graphql +query GetBooks { + # @genqlient(struct: true) + favorite { + title + } +} +``` + +genqlient will generate just: + +```go +type GetBooksFavoriteBook struct { + Title string +} +``` + +Keep in mind that if you later want to add fragments to your selection, you won't be able to use `struct` anymore; when you remove it you may need to update your code to replace `.Title` with `.GetTitle()` and so on. + + ### … documentation on the output types? For any GraphQL types or fields with documentation in the GraphQL schema, genqlient automatically includes that documentation in the generated code's GoDoc. To add additional information to genqlient entrypoints, you can put comments in the GraphQL source: diff --git a/docs/genqlient_directive.graphql b/docs/genqlient_directive.graphql index 328d8b8..7dca682 100644 --- a/docs/genqlient_directive.graphql +++ b/docs/genqlient_directive.graphql @@ -54,6 +54,22 @@ directive genqlient( # zero value and null (for nullable fields). pointer: Boolean + # If set, this field will use a struct type in Go, even if it's an interface. + # + # This is useful when you have a query like + # query MyQuery { + # myInterface { myField } + # } + # where you are requesting only shared fields of an interface. By default, + # genqlient still generates an interface type, for consistency. But this + # isn't necessary: a struct would do just fine since there are no + # type-specific fields. Setting `struct: true` tells genqlient to do that. + # + # Note that this is only allowed when there are no fragments in play, such + # that all fields are on the interface type. Note that if you later add a + # fragment, you'll have to remove this option, and the types will change. + struct: Boolean + # If set, this argument or field will use the given Go type instead of a # genqlient-generated type. # diff --git a/generate/convert.go b/generate/convert.go index 3528746..6e39bac 100644 --- a/generate/convert.go +++ b/generate/convert.go @@ -175,7 +175,14 @@ func (g *generator) convertDefinition( GraphQLName: def.Name, } - switch def.Kind { + // The struct option basically means "treat this as if it were an object". + // (It only applies if valid; this is important if you said the whole + // query should have `struct: true`.) + kind := def.Kind + if options.GetStruct() && validateStructOption(def, selectionSet, pos) == nil { + kind = ast.Object + } + switch kind { case ast.Object: name := makeTypeName(namePrefix, def.Name) @@ -463,7 +470,7 @@ func (g *generator) convertInlineFragment( containingTypedef *ast.Definition, queryOptions *genqlientDirective, ) ([]*goStructField, error) { - // You might think fragmentTypedef would be a fragment.ObjectDefinition, but + // You might think fragmentTypedef is just fragment.ObjectDefinition, but // actually that's the type into which the fragment is spread. fragmentTypedef := g.schema.Types[fragment.TypeCondition] if !fragmentMatches(containingTypedef, fragmentTypedef) { diff --git a/generate/generate.go b/generate/generate.go index 7c6614f..2adbf3c 100644 --- a/generate/generate.go +++ b/generate/generate.go @@ -209,6 +209,8 @@ func (g *generator) preprocessQueryDocument(doc *ast.QueryDocument) { // needed). Note this does mean abstract-typed fragments spread into // object-typed scope will *not* have access to `__typename`, but they // indeed don't need it, since we do know the type in that context. + // TODO(benkraft): We should omit __typename if you asked for + // `# @genqlient(struct: true)`. observers.OnField(func(_ *validator.Walker, field *ast.Field) { // We are interested in a field from the query like // field { subField ... } diff --git a/generate/genqlient_directive.go b/generate/genqlient_directive.go index 43b8ce4..f40e3dd 100644 --- a/generate/genqlient_directive.go +++ b/generate/genqlient_directive.go @@ -14,11 +14,13 @@ type genqlientDirective struct { pos *ast.Position Omitempty *bool Pointer *bool + Struct *bool 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 (dir *genqlientDirective) GetStruct() bool { return dir.Struct != nil && *dir.Struct } func setBool(dst **bool, v *ast.Value) error { ei, err := v.Value(nil) // no vars allowed @@ -44,15 +46,15 @@ func setString(dst *string, v *ast.Value) error { return errorf(v.Position, "expected string, got non-string value %T(%v)", ei, ei) } -func fromGraphQL(dir *ast.Directive) (*genqlientDirective, error) { +func fromGraphQL(dir *ast.Directive, pos *ast.Position) (*genqlientDirective, error) { if dir.Name != "genqlient" { // Actually we just won't get here; we only get here if the line starts // with "# @genqlient", unless there's some sort of bug. - return nil, errorf(dir.Position, "the only valid comment-directive is @genqlient, got %v", dir.Name) + return nil, errorf(pos, "the only valid comment-directive is @genqlient, got %v", dir.Name) } var retval genqlientDirective - retval.pos = dir.Position + retval.pos = pos var err error for _, arg := range dir.Arguments { @@ -62,10 +64,12 @@ func fromGraphQL(dir *ast.Directive) (*genqlientDirective, error) { err = setBool(&retval.Omitempty, arg.Value) case "pointer": err = setBool(&retval.Pointer, arg.Value) + case "struct": + err = setBool(&retval.Struct, arg.Value) case "bind": err = setString(&retval.Bind, arg.Value) default: - return nil, errorf(arg.Position, "unknown argument %v for @genqlient", arg.Name) + return nil, errorf(pos, "unknown argument %v for @genqlient", arg.Name) } if err != nil { return nil, err @@ -74,7 +78,7 @@ func fromGraphQL(dir *ast.Directive) (*genqlientDirective, error) { return &retval, nil } -func (dir *genqlientDirective) validate(node interface{}) error { +func (dir *genqlientDirective) validate(node interface{}, schema *ast.Schema) error { switch node := node.(type) { case *ast.OperationDefinition: if dir.Bind != "" { @@ -90,6 +94,10 @@ func (dir *genqlientDirective) validate(node interface{}) error { return errorf(dir.pos, "bind is not implemented for named fragments") } + if dir.Struct != nil { + return errorf(dir.pos, "struct is only applicable to fields") + } + // Like operations, anything else will just apply to the entire // fragment. return nil @@ -97,17 +105,58 @@ func (dir *genqlientDirective) validate(node interface{}) error { if dir.Omitempty != nil && node.Type.NonNull { return errorf(dir.pos, "omitempty may only be used on optional arguments") } + + if dir.Struct != nil { + return errorf(dir.pos, "struct is only applicable to fields") + } + return nil case *ast.Field: if dir.Omitempty != nil { return errorf(dir.pos, "omitempty is not applicable to fields") } + + if dir.Struct != nil { + typ := schema.Types[node.Definition.Type.Name()] + if err := validateStructOption(typ, node.SelectionSet, dir.pos); err != nil { + return err + } + } + return nil default: return errorf(dir.pos, "invalid @genqlient directive location: %T", node) } } +func validateStructOption( + typ *ast.Definition, + selectionSet ast.SelectionSet, + pos *ast.Position, +) error { + if typ.Kind != ast.Interface && typ.Kind != ast.Union { + return errorf(pos, "struct is only applicable to interface-typed fields") + } + + // Make sure that all the requested fields apply to the interface itself + // (not just certain implementations). + for _, selection := range selectionSet { + switch selection.(type) { + case *ast.Field: + // fields are fine. + case *ast.InlineFragment, *ast.FragmentSpread: + // Fragments aren't allowed. In principle we could allow them under + // the condition that the fragment applies to the whole interface + // (not just one implementation; and so on recursively), and for + // fragment spreads additionally that the fragment has the same + // option applied to it, but it seems more trouble than it's worth + // right now. + return errorf(pos, "struct is not allowed for types with fragments") + } + } + return nil +} + func (dir *genqlientDirective) merge(other *genqlientDirective) *genqlientDirective { retval := *dir if other.Omitempty != nil { @@ -116,6 +165,9 @@ func (dir *genqlientDirective) merge(other *genqlientDirective) *genqlientDirect if other.Pointer != nil { retval.Pointer = other.Pointer } + if other.Struct != nil { + retval.Struct = other.Struct + } if other.Bind != "" { retval.Bind = other.Bind } @@ -141,11 +193,11 @@ func (g *generator) parsePrecedingComment( if err != nil { return "", nil, err } - genqlientDirective, err := fromGraphQL(graphQLDirective) + genqlientDirective, err := fromGraphQL(graphQLDirective, pos) if err != nil { return "", nil, err } - err = genqlientDirective.validate(node) + err = genqlientDirective.validate(node, g.schema) if err != nil { return "", nil, err } diff --git a/generate/testdata/errors/StructOptionOnObject.graphql b/generate/testdata/errors/StructOptionOnObject.graphql new file mode 100644 index 0000000..c33137a --- /dev/null +++ b/generate/testdata/errors/StructOptionOnObject.graphql @@ -0,0 +1,6 @@ +query StructOptionOnObject { + # @genqlient(struct: true) + myObject { + f + } +} diff --git a/generate/testdata/errors/StructOptionOnObject.schema.graphql b/generate/testdata/errors/StructOptionOnObject.schema.graphql new file mode 100644 index 0000000..029fecc --- /dev/null +++ b/generate/testdata/errors/StructOptionOnObject.schema.graphql @@ -0,0 +1,7 @@ +type Query { + myObject: MyObject +} + +type MyObject { + f: String! +} diff --git a/generate/testdata/errors/StructOptionWithFragments.graphql b/generate/testdata/errors/StructOptionWithFragments.graphql new file mode 100644 index 0000000..db81e2c --- /dev/null +++ b/generate/testdata/errors/StructOptionWithFragments.graphql @@ -0,0 +1,9 @@ +query StructOptionOnObject { + # @genqlient(struct: true) + myInterface { + f + ... on MyObject { + g + } + } +} diff --git a/generate/testdata/errors/StructOptionWithFragments.schema.graphql b/generate/testdata/errors/StructOptionWithFragments.schema.graphql new file mode 100644 index 0000000..18c11b7 --- /dev/null +++ b/generate/testdata/errors/StructOptionWithFragments.schema.graphql @@ -0,0 +1,16 @@ +type Query { + myInterface: MyInterface +} + +interface MyInterface { + f: String! +} + +type MyObject implements MyInterface { + f: String! + g: String! +} + +type OtherObject implements MyInterface { + f: String! +} diff --git a/generate/testdata/queries/StructOption.graphql b/generate/testdata/queries/StructOption.graphql new file mode 100644 index 0000000..89391fc --- /dev/null +++ b/generate/testdata/queries/StructOption.graphql @@ -0,0 +1,24 @@ +fragment VideoFields on Video { duration } + +# @genqlient(struct: true) +query StructOption { + root { + id + children { + id + parent { + id + children { + id + } + # (it won't apply to this) + interfaceChildren: children { + id + ...VideoFields + } + } + } + } + # (nor this) + user { roles } +} diff --git a/generate/testdata/snapshots/TestGenerate-StructOption.graphql-StructOption.graphql.go b/generate/testdata/snapshots/TestGenerate-StructOption.graphql-StructOption.graphql.go new file mode 100644 index 0000000..ff55649 --- /dev/null +++ b/generate/testdata/snapshots/TestGenerate-StructOption.graphql-StructOption.graphql.go @@ -0,0 +1,296 @@ +package test + +// Code generated by github.com/Khan/genqlient, DO NOT EDIT. + +import ( + "encoding/json" + "fmt" + + "github.com/Khan/genqlient/graphql" + "github.com/Khan/genqlient/internal/testutil" +) + +// Role is a type a user may have. +type Role string + +const ( + // What is a student? + // + // A student is primarily a person enrolled in a school or other educational institution and who is under learning with goals of acquiring knowledge, developing professions and achieving employment at desired field. In the broader sense, a student is anyone who applies themselves to the intensive intellectual engagement with some matter necessary to master it as part of some practical affair in which such mastery is basic or decisive. + // + // (from [Wikipedia](https://en.wikipedia.org/wiki/Student)) + RoleStudent Role = "STUDENT" + // Teacher is a teacher, who teaches the students. + RoleTeacher Role = "TEACHER" +) + +// StructOptionResponse is returned by StructOption on success. +type StructOptionResponse struct { + Root StructOptionRootTopic `json:"root"` + // user looks up a user by some stuff. + // + // See UserQueryInput for what stuff is supported. + // If query is null, returns the current user. + User StructOptionUser `json:"user"` +} + +// StructOptionRootTopic includes the requested fields of the GraphQL type Topic. +type StructOptionRootTopic struct { + // ID is documented in the Content interface. + Id testutil.ID `json:"id"` + Children []StructOptionRootTopicChildrenContent `json:"children"` +} + +// StructOptionRootTopicChildrenContent includes the requested fields of the GraphQL type Content. +// The GraphQL type's documentation follows. +// +// Content is implemented by various types like Article, Video, and Topic. +type StructOptionRootTopicChildrenContent struct { + Typename string `json:"__typename"` + // ID is the identifier of the content. + Id testutil.ID `json:"id"` + Parent StructOptionRootTopicChildrenContentParentTopic `json:"parent"` +} + +// StructOptionRootTopicChildrenContentParentTopic includes the requested fields of the GraphQL type Topic. +type StructOptionRootTopicChildrenContentParentTopic struct { + // ID is documented in the Content interface. + Id testutil.ID `json:"id"` + Children []StructOptionRootTopicChildrenContentParentTopicChildrenContent `json:"children"` + InterfaceChildren []StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent `json:"-"` +} + +func (v *StructOptionRootTopicChildrenContentParentTopic) UnmarshalJSON(b []byte) error { + + var firstPass struct { + *StructOptionRootTopicChildrenContentParentTopic + InterfaceChildren []json.RawMessage `json:"interfaceChildren"` + graphql.NoUnmarshalJSON + } + firstPass.StructOptionRootTopicChildrenContentParentTopic = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + { + target := &v.InterfaceChildren + raw := firstPass.InterfaceChildren + *target = make( + []StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent, + len(raw)) + for i, raw := range raw { + target := &(*target)[i] + err = __unmarshalStructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent( + target, raw) + if err != nil { + return fmt.Errorf( + "Unable to unmarshal StructOptionRootTopicChildrenContentParentTopic.InterfaceChildren: %w", err) + } + } + } + return nil +} + +// StructOptionRootTopicChildrenContentParentTopicChildrenContent includes the requested fields of the GraphQL type Content. +// The GraphQL type's documentation follows. +// +// Content is implemented by various types like Article, Video, and Topic. +type StructOptionRootTopicChildrenContentParentTopicChildrenContent struct { + Typename string `json:"__typename"` + // ID is the identifier of the content. + Id testutil.ID `json:"id"` +} + +// StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenArticle includes the requested fields of the GraphQL type Article. +type StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenArticle struct { + Typename string `json:"__typename"` + // ID is the identifier of the content. + Id testutil.ID `json:"id"` +} + +// StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent includes the requested fields of the GraphQL interface Content. +// +// StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent is implemented by the following types: +// StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenArticle +// StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenVideo +// StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenTopic +// The GraphQL type's documentation follows. +// +// Content is implemented by various types like Article, Video, and Topic. +type StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent interface { + implementsGraphQLInterfaceStructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent() + // GetTypename returns the receiver's concrete GraphQL type-name (see interface doc for possible values). + GetTypename() string + // GetId returns the interface-field "id" from its implementation. + // The GraphQL interface field's documentation follows. + // + // ID is the identifier of the content. + GetId() testutil.ID +} + +func (v *StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenArticle) implementsGraphQLInterfaceStructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent() { +} + +// GetTypename is a part of, and documented with, the interface StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent. +func (v *StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenArticle) GetTypename() string { + return v.Typename +} + +// GetId is a part of, and documented with, the interface StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent. +func (v *StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenArticle) GetId() testutil.ID { + return v.Id +} + +func (v *StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenVideo) implementsGraphQLInterfaceStructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent() { +} + +// GetTypename is a part of, and documented with, the interface StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent. +func (v *StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenVideo) GetTypename() string { + return v.Typename +} + +// GetId is a part of, and documented with, the interface StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent. +func (v *StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenVideo) GetId() testutil.ID { + return v.Id +} + +func (v *StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenTopic) implementsGraphQLInterfaceStructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent() { +} + +// GetTypename is a part of, and documented with, the interface StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent. +func (v *StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenTopic) GetTypename() string { + return v.Typename +} + +// GetId is a part of, and documented with, the interface StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent. +func (v *StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenTopic) GetId() testutil.ID { + return v.Id +} + +func __unmarshalStructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent(v *StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent, m json.RawMessage) error { + if string(m) == "null" { + return nil + } + + var tn struct { + TypeName string `json:"__typename"` + } + err := json.Unmarshal(m, &tn) + if err != nil { + return err + } + + switch tn.TypeName { + case "Article": + *v = new(StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenArticle) + return json.Unmarshal(m, *v) + case "Video": + *v = new(StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenVideo) + return json.Unmarshal(m, *v) + case "Topic": + *v = new(StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenTopic) + return json.Unmarshal(m, *v) + case "": + return fmt.Errorf( + "Response was missing Content.__typename") + default: + return fmt.Errorf( + `Unexpected concrete type for StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenContent: "%v"`, tn.TypeName) + } +} + +// StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenTopic includes the requested fields of the GraphQL type Topic. +type StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenTopic struct { + Typename string `json:"__typename"` + // ID is the identifier of the content. + Id testutil.ID `json:"id"` +} + +// StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenVideo includes the requested fields of the GraphQL type Video. +type StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenVideo struct { + Typename string `json:"__typename"` + // ID is the identifier of the content. + Id testutil.ID `json:"id"` + VideoFields `json:"-"` +} + +func (v *StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenVideo) UnmarshalJSON(b []byte) error { + + var firstPass struct { + *StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenVideo + graphql.NoUnmarshalJSON + } + firstPass.StructOptionRootTopicChildrenContentParentTopicInterfaceChildrenVideo = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.VideoFields) + if err != nil { + return err + } + return nil +} + +// StructOptionUser includes the requested fields of the GraphQL type User. +// The GraphQL type's documentation follows. +// +// A User is a user! +type StructOptionUser struct { + Roles []Role `json:"roles"` +} + +// VideoFields includes the GraphQL fields of Video requested by the fragment VideoFields. +type VideoFields struct { + Duration int `json:"duration"` +} + +func StructOption( + client graphql.Client, +) (*StructOptionResponse, error) { + var err error + + var retval StructOptionResponse + err = client.MakeRequest( + nil, + "StructOption", + ` +query StructOption { + root { + id + children { + __typename + id + parent { + id + children { + __typename + id + } + interfaceChildren: children { + __typename + id + ... VideoFields + } + } + } + } + user { + roles + } +} +fragment VideoFields on Video { + duration +} +`, + &retval, + nil, + ) + return &retval, err +} + diff --git a/generate/testdata/snapshots/TestGenerate-StructOption.graphql-StructOption.graphql.json b/generate/testdata/snapshots/TestGenerate-StructOption.graphql-StructOption.graphql.json new file mode 100644 index 0000000..3d3aa7a --- /dev/null +++ b/generate/testdata/snapshots/TestGenerate-StructOption.graphql-StructOption.graphql.json @@ -0,0 +1,9 @@ +{ + "operations": [ + { + "operationName": "StructOption", + "query": "\nquery StructOption {\n\troot {\n\t\tid\n\t\tchildren {\n\t\t\t__typename\n\t\t\tid\n\t\t\tparent {\n\t\t\t\tid\n\t\t\t\tchildren {\n\t\t\t\t\t__typename\n\t\t\t\t\tid\n\t\t\t\t}\n\t\t\t\tinterfaceChildren: children {\n\t\t\t\t\t__typename\n\t\t\t\t\tid\n\t\t\t\t\t... VideoFields\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tuser {\n\t\troles\n\t}\n}\nfragment VideoFields on Video {\n\tduration\n}\n", + "sourceLocation": "testdata/queries/StructOption.graphql" + } + ] +} diff --git a/generate/testdata/snapshots/TestGenerateErrors-StructOptionOnObject-graphql b/generate/testdata/snapshots/TestGenerateErrors-StructOptionOnObject-graphql new file mode 100644 index 0000000..65a71dd --- /dev/null +++ b/generate/testdata/snapshots/TestGenerateErrors-StructOptionOnObject-graphql @@ -0,0 +1 @@ +testdata/errors/StructOptionOnObject.graphql:3: struct is only applicable to interface-typed fields diff --git a/generate/testdata/snapshots/TestGenerateErrors-StructOptionWithFragments-graphql b/generate/testdata/snapshots/TestGenerateErrors-StructOptionWithFragments-graphql new file mode 100644 index 0000000..35de573 --- /dev/null +++ b/generate/testdata/snapshots/TestGenerateErrors-StructOptionWithFragments-graphql @@ -0,0 +1 @@ +testdata/errors/StructOptionWithFragments.graphql:3: struct is not allowed for types with fragments