diff --git a/codegen/config/binder.go b/codegen/config/binder.go index 39f2a761dc3..bc15371a7a3 100644 --- a/codegen/config/binder.go +++ b/codegen/config/binder.go @@ -197,19 +197,21 @@ func (b *Binder) PointerTo(ref *TypeReference) *TypeReference { // TypeReference is used by args and field types. The Definition can refer to both input and output // types. type TypeReference struct { - Definition *ast.Definition - GQL *ast.Type - GO types.Type // Type of the field being bound. Could be a pointer or a value type of Target. - Target types.Type // The actual type that we know how to bind to. May require pointer juggling when traversing to fields. - CastType types.Type // Before calling marshalling functions cast from/to this base type - Marshaler *types.Func // When using external marshalling functions this will point to the Marshal function - Unmarshaler *types.Func // When using external marshalling functions this will point to the Unmarshal function - IsMarshaler bool // Does the type implement graphql.Marshaler and graphql.Unmarshaler - IsOmittable bool // Is the type wrapped with Omittable - IsContext bool // Is the Marshaler/Unmarshaller the context version; applies to either the method or interface variety. - PointersInUnmarshalInput bool // Inverse values and pointers in return. - IsRoot bool // Is the type a root level definition such as Query, Mutation or Subscription - EnumValues []EnumValueReference + Definition *ast.Definition + GQL *ast.Type + GO types.Type // Type of the field being bound. Could be a pointer or a value type of Target. + Target types.Type // The actual type that we know how to bind to. May require pointer juggling when traversing to fields. + CastType types.Type // Before calling marshalling functions cast from/to this base type + Marshaler *types.Func // When using external marshalling functions this will point to the Marshal function + Unmarshaler *types.Func // When using external marshalling functions this will point to the Unmarshal function + IsMarshaler bool // Does the type implement graphql.Marshaler and graphql.Unmarshaler + IsOmittable bool // Does the type support omittability via an unmarshaler function + OmittableUnmarshaler *types.Func // If IsOmittable is true, this will point to the unmarshaler function that supports omittability + OmittableUnmarshalerCanError bool // If IsOmittable is true, indicates whether the unmarshaler function returns an error as a second return value + IsContext bool // Is the Marshaler/Unmarshaller the context version; applies to either the method or interface variety. + PointersInUnmarshalInput bool // Inverse values and pointers in return. + IsRoot bool // Is the type a root level definition such as Query, Mutation or Subscription + EnumValues []EnumValueReference } func (ref *TypeReference) Elem() *TypeReference { @@ -362,18 +364,113 @@ func isIntf(t types.Type) bool { return ok } -func unwrapOmittable(t types.Type) (types.Type, bool) { - if t == nil { - return nil, false - } - named, ok := t.(*types.Named) +func (b *Binder) unwrapOmittable( + goType types.Type, + bindTarget types.Type, +) ( + unwrappedType types.Type, + unmarshalOmittable *types.Func, + unmarshalerCanError bool, +) { + if bindTarget == nil { + return nil, nil, false + } + named, ok := bindTarget.(*types.Named) if !ok { - return t, false + return bindTarget, nil, false } - if named.Origin().String() != "github.com/99designs/gqlgen/graphql.Omittable[T any]" { - return t, false + for _, ot := range b.cfg.OmittableType { + pkgName, funName := code.PkgAndType(ot) + + obj, err := b.FindObject(pkgName, funName) + if err != nil { + continue + } + + fn, ok := obj.(*types.Func) + if !ok { + continue + } + + sig := obj.Type().(*types.Signature) + if named.Origin().String() == sig.Results().At(0).Type().(*types.Named).Origin().String() { + // If we instantiate the unmarshaler function with the type arg in the bindTarget, does it result in compatible types? + typeArg := named.TypeArgs().At(0) + + // There are four potential cases to check for compatibility: + // 1) The type arg and goType as-is + // 2) The type arg as a non-pointer (if it's a pointer), and the goType as-is + // 3) The type arg as-is, and the goType as a non-pointer (if it's a pointer) + // 4) Both the type arg and the goType as non-pointers (if they're pointers) + typeArgPtr, isTypeArgPtr := typeArg.(*types.Pointer) + goTypePtr, isGoTypePtr := goType.(*types.Pointer) + + if canError, ok := b.instantiateAndCheckOmittable(fn, typeArg, goType, bindTarget); ok { + return goType, fn, canError + } + if isTypeArgPtr { + if canError, ok := b.instantiateAndCheckOmittable(fn, typeArgPtr.Elem(), goType, bindTarget); ok { + return goType, fn, canError + } + } + if isGoTypePtr { + if canError, ok := b.instantiateAndCheckOmittable(fn, typeArg, goTypePtr.Elem(), bindTarget); ok { + return goTypePtr.Elem(), fn, canError + } + } + if isTypeArgPtr && isGoTypePtr { + if canError, ok := b.instantiateAndCheckOmittable(fn, typeArgPtr.Elem(), goTypePtr.Elem(), bindTarget); ok { + return goTypePtr.Elem(), fn, canError + } + } + } } - return named.TypeArgs().At(0), true + return bindTarget, nil, false +} + +// instantiateAndCheckOmittable attemps to instantiate the given function with the provided type +// argument and checks if the resulting signature is compatible with the expected argument and +// result types for an omittable unmarshaler function. It returns true if the function can be used +// as an omittable unmarshaler for the given type, and false otherwise. +func (b *Binder) instantiateAndCheckOmittable(fn *types.Func, typeArg, expectedArg, expectedResult types.Type) (canError, ok bool) { + ifun, err := b.InstantiateType(fn.Type(), []types.Type{typeArg}) + if err != nil { + return false, false + } + isig := ifun.(*types.Signature) + + // The signature of an omittable unmarshaler function can be one of: + // func[T any](T) U + // func[T any](T) (U, error) + // where U is the type we want to unmarshal into (the bindTarget) and T is the type argument in the function definition. + + // Check the parameters. There should be exactly one parameter of the expected type (the type argument). + if isig.Params().Len() != 1 { + return false, false + } + if isig.Params().At(0).Type().String() != expectedArg.String() { + return false, false + } + + // Check the results. We allow either a single result of the expected type, or a tuple of (expected type, error). + switch isig.Results().Len() { + case 1: + if isig.Results().At(0).Type().String() != expectedResult.String() { + return false, false + } + case 2: + if isig.Results().At(0).Type().String() != expectedResult.String() { + return false, false + } + if isig.Results().At(1).Type().String() != "error" { + return false, false + } + canError = true + default: + return false, false + } + + return canError, true } func (b *Binder) TypeReference( @@ -383,19 +480,6 @@ func (b *Binder) TypeReference( if bindTarget != nil { bindTarget = code.Unalias(bindTarget) } - if innerType, ok := unwrapOmittable(bindTarget); ok { - if schemaType.NonNull { - return nil, fmt.Errorf("%s is wrapped with Omittable but non-null", schemaType.Name()) - } - - ref, err := b.TypeReference(schemaType, innerType) - if err != nil { - return nil, err - } - - ref.IsOmittable = true - return ref, err - } if !isValid(bindTarget) { b.SawInvalid = true @@ -497,6 +581,21 @@ func (b *Binder) TypeReference( if bindTarget != nil { if err = code.CompatibleTypes(ref.GO, bindTarget); err != nil { + // Attempt to unwrap omittable types if the provided bindTarget is not compatible + // with the initial GO type. This allows users to specify their own omittable types. + if newTarget, unmarshalFunc, canError := b.unwrapOmittable(ref.GO, bindTarget); unmarshalFunc != nil { + ref, err := b.TypeReference(schemaType, newTarget) + if err != nil { + return nil, err + } + + ref.IsOmittable = true + ref.OmittableUnmarshaler = unmarshalFunc + ref.OmittableUnmarshalerCanError = canError + + return ref, nil + } + // if the bind type implements the // graphql.ContextMarshaler/graphql.ContextUnmarshaler/graphql.Marshaler/graphql.Unmarshaler // interface, we can use it diff --git a/codegen/config/binder_test.go b/codegen/config/binder_test.go index 2b9f5f003b2..43daf8b9104 100644 --- a/codegen/config/binder_test.go +++ b/codegen/config/binder_test.go @@ -91,41 +91,6 @@ func TestOmittableBinding(t *testing.T) { require.True(t, ta.IsOmittable) }) - t.Run("fail binding non-nullable string with Omittable[string]", func(t *testing.T) { - binder, schema := createBinder(Config{}) - - ot, err := binder.FindType("github.com/99designs/gqlgen/graphql", "Omittable") - require.NoError(t, err) - - it, err := binder.InstantiateType(ot, []types.Type{types.Universe.Lookup("string").Type()}) - require.NoError(t, err) - - _, err = binder.TypeReference( - schema.Types["FooInput"].Fields.ForName("nonNullableString").Type, - it, - ) - require.Error(t, err) - }) - - t.Run("fail binding non-nullable string with Omittable[*string]", func(t *testing.T) { - binder, schema := createBinder(Config{}) - - ot, err := binder.FindType("github.com/99designs/gqlgen/graphql", "Omittable") - require.NoError(t, err) - - it, err := binder.InstantiateType( - ot, - []types.Type{types.NewPointer(types.Universe.Lookup("string").Type())}, - ) - require.NoError(t, err) - - _, err = binder.TypeReference( - schema.Types["FooInput"].Fields.ForName("nonNullableString").Type, - it, - ) - require.Error(t, err) - }) - t.Run("bind nullable object with Omittable[T]", func(t *testing.T) { binder, schema := createBinder(Config{}) @@ -176,6 +141,7 @@ func TestOmittableBinding(t *testing.T) { } func createBinder(cfg Config) (*Binder, *ast.Schema) { + cfg.OmittableType = StringList{"github.com/99designs/gqlgen/graphql.OmittableOf"} cfg.Models = TypeMap{ "Message": TypeMapEntry{ Model: []string{ diff --git a/codegen/config/config.go b/codegen/config/config.go index 39e6dab416a..6d6959c2966 100644 --- a/codegen/config/config.go +++ b/codegen/config/config.go @@ -31,6 +31,7 @@ type Config struct { Resolver ResolverConfig `yaml:"resolver,omitempty"` AutoBind []string `yaml:"autobind"` AutobindGetterHaser bool `yaml:"autobind_getter_haser,omitempty"` + OmittableType StringList `yaml:"omittable_type,omitempty"` Models TypeMap `yaml:"models,omitempty"` StructTag string `yaml:"struct_tag,omitempty"` EmbeddedStructsPrefix string `yaml:"embedded_structs_prefix,omitempty"` @@ -169,6 +170,7 @@ func DefaultConfig() *Config { SchemaFilename: StringList{"schema.graphql"}, Model: PackageConfig{Filename: "models_gen.go"}, Exec: ExecConfig{Filename: "generated.go"}, + OmittableType: StringList{"github.com/99designs/gqlgen/graphql.OmittableOf"}, Directives: map[string]DirectiveConfig{}, Models: TypeMap{}, StructFieldsAlwaysPointers: true, diff --git a/codegen/field.go b/codegen/field.go index 0d805bc90bb..05d428d5fc5 100644 --- a/codegen/field.go +++ b/codegen/field.go @@ -41,6 +41,29 @@ type Field struct { Batch bool // Enable batch resolver for this field } +func isFieldOmittable(field *ast.FieldDefinition) bool { + for _, dir := range field.Directives { + if dir.Name != "goField" { + continue + } + for _, arg := range dir.Arguments { + if arg.Name != "omittable" { + continue + } + v, err := arg.Value.Value(nil) + if err != nil { + continue + } + omittable, ok := v.(bool) + if !ok { + continue + } + return omittable + } + } + return false +} + func (b *builder) buildField(obj *Object, field *ast.FieldDefinition) (*Field, error) { dirs, err := b.getDirectives(field.Directives) if err != nil { @@ -64,6 +87,10 @@ func (b *builder) buildField(obj *Object, field *ast.FieldDefinition) (*Field, e } } + if isFieldOmittable(field) && field.Type.NonNull { + return nil, fmt.Errorf("field %s.%s must be nullable if it is omittable", obj.Name, field.Name) + } + for _, arg := range field.Arguments { newArg, err := b.buildArg(obj, arg) if err != nil { diff --git a/codegen/input.gotpl b/codegen/input.gotpl index dc1f7cdbf21..4916a890960 100644 --- a/codegen/input.gotpl +++ b/codegen/input.gotpl @@ -63,7 +63,15 @@ } {{- else }} {{- if $field.TypeReference.IsOmittable }} - {{ $lhs }} = graphql.OmittableOf(data) + {{- if $field.TypeReference.OmittableUnmarshalerCanError }} + odata, err := {{ $field.TypeReference.OmittableUnmarshaler | call }}(data) + if err != nil { + return {{$it}}, graphql.ErrorOnPath(ctx, err) + } + {{ $lhs }} = odata + {{- else }} + {{ $lhs }} = {{ $field.TypeReference.OmittableUnmarshaler | call }}(data) + {{- end }} {{- else }} {{ $lhs }} = data {{- end }} @@ -72,7 +80,15 @@ {{- if not $field.IsResolver }} } else if tmp == nil { {{- if $field.TypeReference.IsOmittable }} - {{ $lhs }} = graphql.OmittableOf[{{ $field.TypeReference.GO | ref }}](nil) + {{- if $field.TypeReference.OmittableUnmarshalerCanError }} + odata, err := {{ $field.TypeReference.OmittableUnmarshaler | call }}(data) + if err != nil { + return {{$it}}, graphql.ErrorOnPath(ctx, err) + } + {{ $lhs }} = odata + {{- else }} + {{ $lhs }} = {{ $field.TypeReference.OmittableUnmarshaler | call }}(data) + {{- end }} {{- else }} {{ $lhs }} = nil {{- end }} @@ -105,7 +121,15 @@ return {{$it}}, err } {{- if $field.TypeReference.IsOmittable }} - {{ $lhs }} = graphql.OmittableOf(data) + {{- if $field.TypeReference.OmittableUnmarshalerCanError }} + odata, err := {{ $field.TypeReference.OmittableUnmarshaler | call }}(data) + if err != nil { + return {{$it}}, graphql.ErrorOnPath(ctx, err) + } + {{ $lhs }} = odata + {{- else }} + {{ $lhs }} = {{ $field.TypeReference.OmittableUnmarshaler | call }}(data) + {{- end }} {{- else }} {{ $lhs }} = data {{- end }} diff --git a/gqlgen.schema.json b/gqlgen.schema.json index acc52b87179..ad7ddd393b3 100644 --- a/gqlgen.schema.json +++ b/gqlgen.schema.json @@ -124,6 +124,11 @@ "type": "boolean", "default": false }, + "omittable_type": { + "type": "array", + "description": "Custom omittable type definitions to use instead of the default github.com/99designs/gqlgen/graphql.Omittable.", + "items": { "type": "string" } + }, "models": { "description": "Type mapping between the GraphQL and go type systems", "type": "object", diff --git a/graphql/omittable.go b/graphql/omittable.go index 288252d69b3..f22b244cb91 100644 --- a/graphql/omittable.go +++ b/graphql/omittable.go @@ -75,45 +75,14 @@ func (o *Omittable[T]) UnmarshalJSON(bytes []byte) error { } func (o Omittable[T]) MarshalGQL(w io.Writer) { - var value any = o.value - if !o.set { - var zero T - value = zero - } - - switch marshaler := value.(type) { - case Marshaler: - marshaler.MarshalGQL(w) - case ContextMarshaler: - _ = marshaler.MarshalGQLContext(context.Background(), w) - default: - b, _ := json.Marshal(value) - w.Write(b) - } + _ = o.MarshalGQLContext(context.Background(), w) } -func (o *Omittable[T]) UnmarshalGQL(bytes []byte) error { - switch unmarshaler := any(o.value).(type) { - case Unmarshaler: - if err := unmarshaler.UnmarshalGQL(bytes); err != nil { - return err - } - o.set = true - case ContextUnmarshaler: - if err := unmarshaler.UnmarshalGQLContext(context.Background(), bytes); err != nil { - return err - } - o.set = true - default: - if err := json.Unmarshal(bytes, &o.value); err != nil { - return err - } - o.set = true - } - return nil +func (o *Omittable[T]) UnmarshalGQL(v any) error { + return o.UnmarshalGQLContext(context.Background(), v) } -func (o Omittable[T]) MarshalGQLContext(ctx context.Context, w io.Writer) { +func (o Omittable[T]) MarshalGQLContext(ctx context.Context, w io.Writer) error { var value any = o.value if !o.set { var zero T @@ -122,28 +91,38 @@ func (o Omittable[T]) MarshalGQLContext(ctx context.Context, w io.Writer) { switch marshaler := value.(type) { case ContextMarshaler: - _ = marshaler.MarshalGQLContext(ctx, w) + if err := marshaler.MarshalGQLContext(ctx, w); err != nil { + return err + } case Marshaler: marshaler.MarshalGQL(w) default: - b, _ := json.Marshal(value) + b, err := json.Marshal(value) + if err != nil { + return err + } w.Write(b) } + return nil } -func (o *Omittable[T]) UnmarshalGQLContext(ctx context.Context, bytes []byte) error { +func (o *Omittable[T]) UnmarshalGQLContext(ctx context.Context, v any) error { switch unmarshaler := any(o.value).(type) { case ContextUnmarshaler: - if err := unmarshaler.UnmarshalGQLContext(ctx, bytes); err != nil { + if err := unmarshaler.UnmarshalGQLContext(ctx, v); err != nil { return err } o.set = true case Unmarshaler: - if err := unmarshaler.UnmarshalGQL(bytes); err != nil { + if err := unmarshaler.UnmarshalGQL(v); err != nil { return err } o.set = true default: + bytes, err := json.Marshal(v) + if err != nil { + return err + } if err := json.Unmarshal(bytes, &o.value); err != nil { return err } diff --git a/init-templates/gqlgen.yml.gotmpl b/init-templates/gqlgen.yml.gotmpl index ef6e2657494..85c3fde0d56 100644 --- a/init-templates/gqlgen.yml.gotmpl +++ b/init-templates/gqlgen.yml.gotmpl @@ -159,6 +159,11 @@ call_argument_directives_with_null: true autobind: # - "{{.}}/graph/model" +# Optional: can be used to specify custom omittable type unmarshallers for input fields. For example, if you wanted to use +# github.com/samber/mo.Option[T] for omittable fields instead of the built-in graphql.Omittable[T] +# omittable_type: +# - github.com/99designs/gqlgen/graphql.OmittableOf + # This section declares type mapping between the GraphQL and go type systems # # The first line in each type will be used as defaults for resolver arguments and diff --git a/plugin/modelgen/models.go b/plugin/modelgen/models.go index 15542c64242..24042aa2a11 100644 --- a/plugin/modelgen/models.go +++ b/plugin/modelgen/models.go @@ -9,6 +9,7 @@ import ( "strings" "text/template" + "github.com/99designs/gqlgen/internal/code" "github.com/vektah/gqlparser/v2/ast" "github.com/99designs/gqlgen/codegen/config" @@ -494,20 +495,65 @@ func (m *Plugin) generateField( ) } - omittableType, err := binder.FindTypeFromName( - "github.com/99designs/gqlgen/graphql.Omittable", - ) + omittableType, err := buildOmittableType(cfg, binder, f.Type) if err != nil { - return nil, err + return nil, fmt.Errorf("generror: field %v.%v: %w", schemaType.Name, field.Name, err) } - f.Type, err = binder.InstantiateType(omittableType, []types.Type{f.Type}) + f.Type = omittableType + } + + return f, nil +} + +func buildOmittableType(cfg *config.Config, binder *config.Binder, typ types.Type) (types.Type, error) { + for _, ot := range cfg.OmittableType { + pkgName, funName := code.PkgAndType(ot) + + obj, err := binder.FindObject(pkgName, funName) if err != nil { - return nil, fmt.Errorf("generror: field %v.%v: %w", schemaType.Name, field.Name, err) + continue + } + + fn, ok := obj.(*types.Func) + if !ok { + continue + } + + if otype, ok := tryInstantiateOmittableType(binder, fn, typ, typ); ok { + return otype, nil + } + + // Try and instantiate without pointer type + ptrTyp, ok := typ.(*types.Pointer) + if !ok { + continue + } + + if otype, ok := tryInstantiateOmittableType(binder, fn, ptrTyp.Elem(), typ); ok { + return otype, nil } } - return f, nil + return nil, fmt.Errorf("generror: no suitable omittable type found for type %v", typ) +} + +func tryInstantiateOmittableType(binder *config.Binder, fn *types.Func, instType, argType types.Type) (types.Type, bool) { + ifn, err := binder.InstantiateType(fn.Type(), []types.Type{instType}) + if err != nil { + return nil, false + } + + isig := ifn.(*types.Signature) + if isig.Params().Len() != 1 { + return nil, false + } + + if isig.Params().At(0).Type().String() != argType.String() { + return nil, false + } + + return isig.Results().At(0).Type(), true } func getExtraFields(cfg *config.Config, modelName string) []*Field {