Skip to content

Commit a3b52ca

Browse files
authored
Merge pull request #1 from digits/nullable-defaults
Add Option to Treat Zero Values as Nil
2 parents 1b59d66 + e5ac804 commit a3b52ca

File tree

5 files changed

+116
-9
lines changed

5 files changed

+116
-9
lines changed

graphql.go

+27
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,33 @@ func UseFieldResolvers() SchemaOpt {
9090
}
9191
}
9292

93+
// AllowNullableZeroValues specifies whether to treat zero-valued scalars (e.g. empty strings, 0 for numbers, etc.)
94+
// as null when resolved to nullable fields.
95+
// When this option is enabled, the behavior is as follows:
96+
// - Nullable fields are now allowed to resolve to concrete (non-pointer) types.
97+
// - In the event a nullable field resolves to a concrete type, the type's zero-value will be marshalled as "null".
98+
// - Non-null fields are now allowed to resolve to pointer types.
99+
// - In the event a non-null field resolves to a pointer type, it's runtime value must always be non-nil, otherwise
100+
// a runtime error is generated.
101+
// Advantages:
102+
// - This enables seamless interoperabiltiy with interfaces from other packages, notably those which eschew pointers
103+
// in favor of zero-valued concrete types to denote non-existance.
104+
// - Specifically, the proto3 spec, and golang/protobuf, do not use pointers for scalar values. This option enables
105+
// outputting those types directly as GraphQL, eliminating significant boilerplate. Similarly, golang/protobuf
106+
// uses pointers to reference all embedded objects, even those that are required. This option enables support
107+
// for this as well, provided the value for non-null fields is always not nil.
108+
// Disadvantages:
109+
// - Flexibility comes at a cost. With this option enabled, non-null fields will freely resolve to pointers,
110+
// removing schema validation of "required" fields and deferring it to run-time synthesized errors.
111+
// - With this option enabled, it is not possible to distinguish between a zero-valued optional field, and null. For
112+
// example, the value 0 will never be marshalled for nullable fields. If this distinction is important, you must
113+
// specify that the field be non-null.
114+
func AllowNullableZeroValues() SchemaOpt {
115+
return func(s *Schema) {
116+
s.schema.AllowNullableZeroValues = true
117+
}
118+
}
119+
93120
// MaxDepth specifies the maximum field nesting depth in a query. The default is 0 which disables max depth checking.
94121
func MaxDepth(n int) SchemaOpt {
95122
return func(s *Schema) {

graphql_test.go

+53-3
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,56 @@ func TestNilInterface(t *testing.T) {
410410
})
411411
}
412412

413+
type testNullableZeroValuesResolver struct{}
414+
type testNullableZeroValuesInternalResolver struct {
415+
Z int32
416+
}
417+
418+
func (r *testNullableZeroValuesResolver) A() *testNullableZeroValuesInternalResolver {
419+
return &testNullableZeroValuesInternalResolver{
420+
Z: 10,
421+
}
422+
}
423+
424+
func (r *testNullableZeroValuesResolver) B() *testNullableZeroValuesInternalResolver {
425+
return &testNullableZeroValuesInternalResolver{
426+
Z: 0,
427+
}
428+
}
429+
430+
func TestNullableZeroValues(t *testing.T) {
431+
gqltesting.RunTests(t, []*gqltesting.Test{
432+
{
433+
Schema: graphql.MustParseSchema(`
434+
schema {
435+
query: Query
436+
}
437+
438+
type Query {
439+
a: T
440+
b: T
441+
}
442+
443+
type T {
444+
z: Int
445+
}
446+
`, &testNullableZeroValuesResolver{}, graphql.AllowNullableZeroValues(), graphql.UseFieldResolvers()),
447+
Query: `
448+
{
449+
a { z }
450+
b { z }
451+
}
452+
`,
453+
ExpectedResult: `
454+
{
455+
"a": { "z": 10 },
456+
"b": { "z": null }
457+
}
458+
`,
459+
},
460+
})
461+
}
462+
413463
func TestErrorPropagationInLists(t *testing.T) {
414464
t.Parallel()
415465

@@ -515,7 +565,7 @@ func TestErrorPropagationInLists(t *testing.T) {
515565
`,
516566
ExpectedErrors: []*gqlerrors.QueryError{
517567
&gqlerrors.QueryError{
518-
Message: `graphql: got nil for non-null "Droid"`,
568+
Message: `got nil for non-null "Droid"`,
519569
Path: []interface{}{"findNilDroids", 1},
520570
},
521571
},
@@ -632,7 +682,7 @@ func TestErrorPropagationInLists(t *testing.T) {
632682
Path: []interface{}{"findNilDroids", 0, "quotes"},
633683
},
634684
&gqlerrors.QueryError{
635-
Message: `graphql: got nil for non-null "Droid"`,
685+
Message: `got nil for non-null "Droid"`,
636686
Path: []interface{}{"findNilDroids", 1},
637687
},
638688
},
@@ -2673,7 +2723,7 @@ func TestComposedFragments(t *testing.T) {
26732723
var (
26742724
exampleError = fmt.Errorf("This is an error")
26752725

2676-
nilChildErrorString = `graphql: got nil for non-null "Child"`
2726+
nilChildErrorString = `got nil for non-null "Child"`
26772727
)
26782728

26792729
type childResolver struct{}

internal/exec/exec.go

+20-3
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio
247247
// function to resolve the field returned null or because an error occurred),
248248
// add an error to the "errors" list in the response.
249249
if nonNull {
250-
err := errors.Errorf("graphql: got nil for non-null %q", t)
250+
err := errors.Errorf("got nil for non-null %q", t)
251251
err.Path = path.toSlice()
252252
r.AddError(err)
253253
}
@@ -259,11 +259,28 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio
259259
return
260260
}
261261

262-
if !nonNull {
263-
if resolver.IsNil() {
262+
if nonNull {
263+
// If we allow nullable zero values, but hit a nil pointer for a required field, it's an error
264+
if s.AllowNullableZeroValues && resolver.Kind() == reflect.Ptr && resolver.IsNil() {
265+
err := errors.Errorf("got nil for non-null %q %v", t, path.toSlice())
266+
err.Path = path.toSlice()
267+
r.AddError(err)
268+
out.WriteString("null")
269+
return
270+
}
271+
} else {
272+
// If this is an optional field, write out null if we have encountered a nil pointer
273+
if (resolver.Kind() == reflect.Ptr && resolver.IsNil()) ||
274+
// Or, if the option is enabled, if we have encountered a zero-value
275+
(s.AllowNullableZeroValues && reflect.DeepEqual(resolver.Interface(), reflect.Zero(resolver.Type()).Interface())) {
264276
out.WriteString("null")
265277
return
266278
}
279+
}
280+
281+
// If it's a pointer, dereference it before continuing. All resolvers below
282+
// expect concrete types.
283+
if resolver.Kind() == reflect.Ptr {
267284
resolver = resolver.Elem()
268285
}
269286

internal/exec/resolvable/resolvable.go

+14-2
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,22 @@ func (b *execBuilder) makeExec(t common.Type, resolverType reflect.Type) (Resolv
167167
return b.makeObjectExec(t.Name, nil, t.PossibleTypes, nonNull, resolverType)
168168
}
169169

170-
if !nonNull {
171-
if resolverType.Kind() != reflect.Ptr {
170+
// If we have not enabled support for nullable default values, enforce pointer expectations
171+
if !b.schema.AllowNullableZeroValues {
172+
// If the field is required, it cannot be resolved by a pointer
173+
if nonNull && resolverType.Kind() == reflect.Ptr {
174+
return nil, fmt.Errorf("%s is a pointer", resolverType)
175+
}
176+
177+
// If the field is optional, is must be resolved by a pointer
178+
if !nonNull && resolverType.Kind() != reflect.Ptr {
172179
return nil, fmt.Errorf("%s is not a pointer", resolverType)
173180
}
181+
}
182+
183+
// If it's a pointer, dereference it before continuing. All resolvers below
184+
// expect concrete types.
185+
if resolverType.Kind() == reflect.Ptr {
174186
resolverType = resolverType.Elem()
175187
}
176188

internal/schema/schema.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ type Schema struct {
4141
// http://facebook.github.io/graphql/draft/#sec-Type-System.Directives
4242
Directives map[string]*DirectiveDecl
4343

44-
UseFieldResolvers bool
44+
UseFieldResolvers bool
45+
AllowNullableZeroValues bool
4546

4647
entryPointNames map[string]string
4748
objects []*Object

0 commit comments

Comments
 (0)