close
Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Note that genqlient now requires Go 1.23 or higher, and is tested through Go 1.2
### New features:

- Added `--version` flag to print version information including commit hash and build date
- Added `omit_unreferenced_implementations` config option to collapse unfragmented interface/union implementations into a single catch-all struct (fixes [#416](https://github.com/Khan/genqlient/issues/416)).

### Bug fixes:

Expand Down
21 changes: 21 additions & 0 deletions docs/genqlient.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,27 @@ use_struct_references: boolean
# Defaults to false.
use_extensions: boolean

# If set, genqlient will skip generating per-type structs for interface
# and union implementations that aren't referenced via an inline fragment
# or named fragment spread in the selection set. Instead, a single
# catch-all struct is generated per interface selection point, carrying
# only the interface's shared fields (including __typename). At runtime,
# any __typename returned by the server that doesn't match one of the
# explicitly-referenced types is decoded into the catch-all -- preserving
# the graceful fallback behavior that existed before this option was
# added, while dramatically reducing the size of generated code for
# interfaces with many implementations (e.g. Relay-style Node).
#
# Type conditions that name an interface or union (rather than a concrete
# Object type) are NOT treated as referencing any concrete implementation:
# the optimization is only defeated by inline fragments or named fragments
# whose type condition is itself an Object type. This means a fragment on
# Node spread at a Node-typed field still collapses to the catch-all
# unless one of its (possibly nested) fragments targets a concrete type.
#
# Defaults to false.
omit_unreferenced_implementations: boolean

# Customize how models are generated for optional fields. This can currently
# be set to one of the following values:
# - value (default): optional fields are generated as values, the same as
Expand Down
2 changes: 2 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ type GetBooksFavoriteBook struct {

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.

If your interface or union has many implementations and your query only fragments a few, set [`omit_unreferenced_implementations`](genqlient.yaml) in `genqlient.yaml`. genqlient will then generate per-type structs only for the implementations actually referenced by a fragment; everything else decodes into a single catch-all struct (`<…>GenqlientOther`) carrying just the interface's shared fields. This dramatically reduces generated code size for Relay-style `Node` interfaces, and unknown `__typename` values from the server (e.g. implementations added after the client was generated) decode into the catch-all rather than failing.

## Sharing types

By default, genqlient generates a different type for each part of each query, [even those which are structurally the same](faq.md#why-does-genqlient-generate-such-complicated-type-names-). Sometimes, however, you want to have some common code that can accept data from several queries or parts of queries.
Expand Down
29 changes: 15 additions & 14 deletions generate/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,21 @@ type Config struct {
// The following fields are documented in the [genqlient.yaml docs].
//
// [genqlient.yaml docs]: https://github.com/Khan/genqlient/blob/main/docs/genqlient.yaml
Schema StringList `yaml:"schema"`
Operations StringList `yaml:"operations"`
Generated string `yaml:"generated"`
Package string `yaml:"package"`
ExportOperations string `yaml:"export_operations"`
ContextType string `yaml:"context_type"`
ClientGetter string `yaml:"client_getter"`
Bindings map[string]*TypeBinding `yaml:"bindings"`
PackageBindings []*PackageBinding `yaml:"package_bindings"`
Casing Casing `yaml:"casing"`
Optional string `yaml:"optional"`
OptionalGenericType string `yaml:"optional_generic_type"`
StructReferences bool `yaml:"use_struct_references"`
Extensions bool `yaml:"use_extensions"`
Schema StringList `yaml:"schema"`
Operations StringList `yaml:"operations"`
Generated string `yaml:"generated"`
Package string `yaml:"package"`
ExportOperations string `yaml:"export_operations"`
ContextType string `yaml:"context_type"`
ClientGetter string `yaml:"client_getter"`
Bindings map[string]*TypeBinding `yaml:"bindings"`
PackageBindings []*PackageBinding `yaml:"package_bindings"`
Casing Casing `yaml:"casing"`
Optional string `yaml:"optional"`
OptionalGenericType string `yaml:"optional_generic_type"`
StructReferences bool `yaml:"use_struct_references"`
Extensions bool `yaml:"use_extensions"`
OmitUnreferencedImplementations bool `yaml:"omit_unreferenced_implementations"`

// The directory of the config-file (relative to which all the other paths
// are resolved). Set by ValidateAndFillDefaults.
Expand Down
134 changes: 128 additions & 6 deletions generate/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ package generate

import (
"fmt"
"slices"
"sort"

"github.com/vektah/gqlparser/v2/ast"
Expand Down Expand Up @@ -522,15 +523,17 @@ func (g *generator) convertDefinition(
implementationTypes := g.schema.GetPossibleTypes(def)
// Make sure we generate stable output by sorting the types by name when we get them
sort.Slice(implementationTypes, func(i, j int) bool { return implementationTypes[i].Name < implementationTypes[j].Name })

filteredImpls := g.filterReferencedImpls(implementationTypes, selectionSet)

goType := &goInterfaceType{
GoName: name,
SharedFields: sharedFields,
Implementations: make([]*goStructType, len(implementationTypes)),
Selection: selectionSet,
descriptionInfo: desc,
}

for i, implDef := range implementationTypes {
for _, implDef := range filteredImpls {
// TODO(benkraft): In principle we should skip generating a Go
// field for __typename each of these impl-defs if you didn't
// request it (and it was automatically added by
Expand All @@ -548,8 +551,13 @@ func (g *generator) convertDefinition(
pos, "interface %s had non-object implementation %s",
def.Name, implDef.Name)
}
goType.Implementations[i] = implStructTyp
goType.Implementations = append(goType.Implementations, implStructTyp)
}

if err := g.attachCatchAll(goType, def.Name, pos); err != nil {
return nil, err
}

return g.addType(goType, goType.GoName, pos)

case ast.Enum:
Expand Down Expand Up @@ -713,6 +721,105 @@ func (g *generator) convertSelectionSet(
return uniqFields, nil
}

// filterReferencedImpls returns the implementations actually referenced by a
// fragment in selSet. When OmitUnreferencedImplementations is disabled, impls
// is returned unchanged.
//
// A type condition that names an interface or union doesn't reference any
// concrete type directly — only its (transitive) fragment body can.
func (g *generator) filterReferencedImpls(impls []*ast.Definition, selSet ast.SelectionSet) []*ast.Definition {
if !g.Config.OmitUnreferencedImplementations {
return impls
}
referenced := map[string]bool{}
queue := []ast.SelectionSet{selSet}
for len(queue) > 0 {
ss := queue[0]
queue = queue[1:]
for _, s := range ss {
var condName string
var fragSet ast.SelectionSet
switch s := s.(type) {
case *ast.InlineFragment:
condName = s.TypeCondition
fragSet = s.SelectionSet
case *ast.FragmentSpread:
if s.Definition == nil {
continue
}
condName = s.Definition.TypeCondition
fragSet = s.Definition.SelectionSet
default:
continue
}
if d := g.schema.Types[condName]; d != nil && d.Kind == ast.Object {
referenced[condName] = true
}
queue = append(queue, fragSet)
}
}

return slices.DeleteFunc(slices.Clone(impls), func(impl *ast.Definition) bool {
return !referenced[impl.Name]
})
}

// attachCatchAll, when OmitUnreferencedImplementations is enabled,
// builds a catch-all struct for iface (registering it via g.addType) and
// assigns it to iface.OtherImplementation. The catch-all carries iface's
// SharedFields (which always include __typename); the unmarshal-helper
// instantiates it whenever the server returns a __typename without an
// explicitly-referenced implementation.
//
// Embedded fragment-spreads whose GoType is itself a *goInterfaceType are
// swapped for that fragment's catch-all, so the result is a valid Go
// struct embedding only structs.
func (g *generator) attachCatchAll(iface *goInterfaceType, graphQLName string, pos *ast.Position) error {
if !g.Config.OmitUnreferencedImplementations {
return nil
}

catchAllFields := make([]*goStructField, 0, len(iface.SharedFields))
for _, f := range iface.SharedFields {
if f.GoName != "" {
catchAllFields = append(catchAllFields, f)
continue
}
embedded, ok := f.GoType.(*goInterfaceType)
if !ok {
catchAllFields = append(catchAllFields, f)
continue
}
// embedded.OtherImplementation is guaranteed non-nil here:
// maybeAttachCatchAll only runs under
// OmitUnreferencedImplementations, and the same option ensures
// every embedded fragment-interface has its own catch-all.
swapped := *f
swapped.GoType = embedded.OtherImplementation
catchAllFields = append(catchAllFields, &swapped)
}

catchAll := &goStructType{
GoName: iface.GoName + "GenqlientOther",
Fields: catchAllFields,
Selection: iface.Selection,
descriptionInfo: descriptionInfo{
GraphQLName: graphQLName,
CommentOverride: fmt.Sprintf(
"%sGenqlientOther is the catch-all for %s implementations "+
"that aren't explicitly fragmented; the concrete "+
"type-name is in __typename.",
iface.GoName, iface.GoName),
},
Generator: g,
}
if _, err := g.addType(catchAll, catchAll.GoName, pos); err != nil {
return err
}
iface.OtherImplementation = catchAll
return nil
}

// fragmentMatches returns true if the given fragment is "active" when applied
// to the given type.
//
Expand Down Expand Up @@ -824,11 +931,20 @@ func (g *generator) convertFragmentSpread(
// type FA struct { ... }
// // (other implementations)
// when you spread F into a context of type A, we embed FA, not F.
matched := false
for _, impl := range iface.Implementations {
if impl.GraphQLName == containingTypedef.Name {
typ = impl
matched = true
break
}
}
if !matched && iface.OtherImplementation != nil {
// The per-implementation struct may have been omitted by
// OmitUnreferencedImplementations; fall back to the catch-all,
// which is a real struct with the same shared fields.
typ = iface.OtherImplementation
}
}

// TODO(benkraft): Set directive here if we ever allow @genqlient
Expand Down Expand Up @@ -886,16 +1002,18 @@ func (g *generator) convertNamedFragment(fragment *ast.FragmentDefinition) (goTy
implementationTypes := g.schema.GetPossibleTypes(typ)
// Make sure we generate stable output by sorting the types by name when we get them
sort.Slice(implementationTypes, func(i, j int) bool { return implementationTypes[i].Name < implementationTypes[j].Name })

filteredImpls := g.filterReferencedImpls(implementationTypes, fragment.SelectionSet)

goType := &goInterfaceType{
GoName: fragment.Name,
SharedFields: fields,
Implementations: make([]*goStructType, len(implementationTypes)),
Selection: fragment.SelectionSet,
descriptionInfo: desc,
}
g.typeMap[fragment.Name] = goType

for i, implDef := range implementationTypes {
for _, implDef := range filteredImpls {
implFields, err := g.convertSelectionSet(
newPrefixList(fragment.Name), fragment.SelectionSet, implDef, directive)
if err != nil {
Expand All @@ -912,10 +1030,14 @@ func (g *generator) convertNamedFragment(fragment *ast.FragmentDefinition) (goTy
descriptionInfo: implDesc,
Generator: g,
}
goType.Implementations[i] = implTyp
goType.Implementations = append(goType.Implementations, implTyp)
g.typeMap[implTyp.GoName] = implTyp
}

if err := g.attachCatchAll(goType, typ.Name, fragment.Position); err != nil {
return nil, err
}

return goType, nil
default:
return nil, errorf(fragment.Position, "invalid type for fragment: %v is a %v",
Expand Down
9 changes: 6 additions & 3 deletions generate/description.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,12 @@ func structDescription(typ *goStructType) string {
}

func interfaceDescription(typ *goInterfaceType) string {
goImplNames := make([]string, len(typ.Implementations))
for i, impl := range typ.Implementations {
goImplNames[i] = impl.Reference()
goImplNames := make([]string, 0, len(typ.Implementations)+1)
for _, impl := range typ.Implementations {
goImplNames = append(goImplNames, impl.Reference())
}
if typ.OtherImplementation != nil {
goImplNames = append(goImplNames, typ.OtherImplementation.Reference())
}
implementationList := fmt.Sprintf(
"\n\n%v is implemented by the following types:\n\t%v",
Expand Down
13 changes: 13 additions & 0 deletions generate/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,19 @@ func TestGenerateWithConfig(t *testing.T) {
},
},
},
{
"OmitUnreferencedImplementations", "", []string{
"SimpleInlineFragment.graphql",
"SimpleNamedFragment.graphql",
"InterfaceNoFragments.graphql",
"OmitImplsNamedFragmentOnInterface.graphql",
"OmitImplsUnion.graphql",
"OmitImplsAbstractFragmentInConcrete.graphql",
"OmitImplsAllImplsReferenced.graphql",
}, &Config{
OmitUnreferencedImplementations: true,
},
},
}

for _, test := range tests {
Expand Down
17 changes: 17 additions & 0 deletions generate/marshal_helper.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
func __marshal{{.GoName}}(v *{{.GoName}}) ([]byte, error) {
{{/* Determine the GraphQL typename, which the unmarshaler will need should
it be called on our output. */}}
{{if .Implementations -}}
var typename string
{{end -}}
switch v := (*v).(type) {
{{range .Implementations -}}
case *{{.GoName}}:
Expand Down Expand Up @@ -35,6 +37,21 @@ func __marshal{{.GoName}}(v *{{.GoName}}) ([]byte, error) {
{{end -}}
return json.Marshal(result)
{{end -}}
{{if .OtherImplementation -}}
case *{{.OtherImplementation.GoName}}:
{{/* Catch-all from OmitUnreferencedImplementations. Its own
__typename field carries the GraphQL type-name, so no
wrapper struct is needed. */ -}}
{{if .OtherImplementation.NeedsMarshaling -}}
premarshaled, err := v.__premarshalJSON()
if err != nil {
return nil, err
}
return json.Marshal(premarshaled)
{{else -}}
return json.Marshal(v)
{{end -}}
{{end -}}
case nil:
return []byte("null"), nil
default:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
fragment ContentBasics on Content {
id
name
}

query OmitImplsAbstractFragmentInConcrete {
randomItem {
id
... on Article {
text
...ContentBasics
}
}
}
13 changes: 13 additions & 0 deletions generate/testdata/queries/OmitImplsAllImplsReferenced.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
query OmitImplsAllImplsReferenced {
randomLeaf {
__typename
... on Article {
id
text
}
... on Video {
id
duration
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
fragment NamedContentFields on Content {
id
name
... on Article {
text
}
}

query OmitImplsNamedFragmentOnInterface {
randomItem {
...NamedContentFields
}
}
Loading