Skip to content

Commit dfbcbfc

Browse files
committed
add nullable relationship
1 parent 80e11a9 commit dfbcbfc

File tree

6 files changed

+354
-13
lines changed

6 files changed

+354
-13
lines changed

Diff for: models_test.go

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type WithNullableAttrs struct {
4242
RFC3339Time NullableAttr[time.Time] `jsonapi:"attr,rfc3339_time,rfc3339,omitempty"`
4343
ISO8601Time NullableAttr[time.Time] `jsonapi:"attr,iso8601_time,iso8601,omitempty"`
4444
Bool NullableAttr[bool] `jsonapi:"attr,bool,omitempty"`
45+
NullableComment NullableRelationship[*Comment] `jsonapi:"relation,nullable_comment,omitempty"`
4546
}
4647

4748
type Car struct {

Diff for: nullable.go

+91
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,35 @@ import (
2626
// Adapted from https://www.jvt.me/posts/2024/01/09/go-json-nullable/
2727
type NullableAttr[T any] map[bool]T
2828

29+
// NullableRelationship is a generic type, which implements a field that can be one of three states:
30+
//
31+
// - relationship is not set in the request
32+
// - relationship is explicitly set to `null` in the request
33+
// - relationship is explicitly set to a valid relationship value in the request
34+
//
35+
// NullableRelationship is intended to be used with JSON marshalling and unmarshalling.
36+
// This is generally useful for PATCH requests, where relationships with zero
37+
// values are intentionally not marshaled into the request payload so that
38+
// existing attribute values are not overwritten.
39+
//
40+
// Internal implementation details:
41+
//
42+
// - map[true]T means a value was provided
43+
// - map[false]T means an explicit null was provided
44+
// - nil or zero map means the field was not provided
45+
//
46+
// If the relationship is expected to be optional, add the `omitempty` JSON tags. Do NOT use `*NullableRelationship`!
47+
//
48+
// Slice types are not currently supported for NullableRelationships as the nullable nature can be expressed via empty array
49+
// `polyrelation` JSON tags are NOT currently supported.
50+
//
51+
// NullableRelationships must have an inner type of pointer:
52+
//
53+
// - NullableRelationship[*Comment] - valid
54+
// - NullableRelationship[[]*Comment] - invalid
55+
// - NullableRelationship[Comment] - invalid
56+
type NullableRelationship[T any] map[bool]T
57+
2958
// NewNullableAttrWithValue is a convenience helper to allow constructing a
3059
// NullableAttr with a given value, for instance to construct a field inside a
3160
// struct without introducing an intermediate variable.
@@ -87,3 +116,65 @@ func (t NullableAttr[T]) IsSpecified() bool {
87116
func (t *NullableAttr[T]) SetUnspecified() {
88117
*t = map[bool]T{}
89118
}
119+
120+
// NewNullableAttrWithValue is a convenience helper to allow constructing a
121+
// NullableAttr with a given value, for instance to construct a field inside a
122+
// struct without introducing an intermediate variable.
123+
func NewNullableRelationshipWithValue[T any](t T) NullableRelationship[T] {
124+
var n NullableRelationship[T]
125+
n.Set(t)
126+
return n
127+
}
128+
129+
// NewNullNullableAttr is a convenience helper to allow constructing a NullableAttr with
130+
// an explicit `null`, for instance to construct a field inside a struct
131+
// without introducing an intermediate variable
132+
func NewNullNullableRelationship[T any]() NullableRelationship[T] {
133+
var n NullableRelationship[T]
134+
n.SetNull()
135+
return n
136+
}
137+
138+
// Get retrieves the underlying value, if present, and returns an error if the value was not present
139+
func (t NullableRelationship[T]) Get() (T, error) {
140+
var empty T
141+
if t.IsNull() {
142+
return empty, errors.New("value is null")
143+
}
144+
if !t.IsSpecified() {
145+
return empty, errors.New("value is not specified")
146+
}
147+
return t[true], nil
148+
}
149+
150+
// Set sets the underlying value to a given value
151+
func (t *NullableRelationship[T]) Set(value T) {
152+
*t = map[bool]T{true: value}
153+
}
154+
155+
// Set sets the underlying value to a given value
156+
func (t *NullableRelationship[T]) SetInterface(value interface{}) {
157+
t.Set(value.(T))
158+
}
159+
160+
// IsNull indicates whether the field was sent, and had a value of `null`
161+
func (t NullableRelationship[T]) IsNull() bool {
162+
_, foundNull := t[false]
163+
return foundNull
164+
}
165+
166+
// SetNull sets the value to an explicit `null`
167+
func (t *NullableRelationship[T]) SetNull() {
168+
var empty T
169+
*t = map[bool]T{false: empty}
170+
}
171+
172+
// IsSpecified indicates whether the field was sent
173+
func (t NullableRelationship[T]) IsSpecified() bool {
174+
return len(t) != 0
175+
}
176+
177+
// SetUnspecified sets the value to be absent from the serialized payload
178+
func (t *NullableRelationship[T]) SetUnspecified() {
179+
*t = map[bool]T{}
180+
}

Diff for: request.go

+36-8
Original file line numberDiff line numberDiff line change
@@ -488,10 +488,30 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*includ
488488

489489
buf := bytes.NewBuffer(nil)
490490

491-
json.NewEncoder(buf).Encode(
492-
data.Relationships[args[1]],
493-
)
494-
json.NewDecoder(buf).Decode(relationship)
491+
relDataStr := data.Relationships[args[1]]
492+
json.NewEncoder(buf).Encode(relDataStr)
493+
494+
isExplicitNull := false
495+
relationshipDecodeErr := json.NewDecoder(buf).Decode(relationship)
496+
if relationshipDecodeErr == nil && relationship.Data == nil {
497+
// If the relationship was a valid node and relationship data was null
498+
// this indicates disassociating the relationship
499+
isExplicitNull = true
500+
} else if relationshipDecodeErr != nil {
501+
er = fmt.Errorf("decode err %v\n", relationshipDecodeErr)
502+
}
503+
504+
// This will hold either the value of the choice type model or the actual
505+
// model, depending on annotation
506+
m := reflect.New(fieldValue.Type().Elem())
507+
508+
// Nullable relationships have an extra pointer indirection
509+
// unwind that here
510+
if strings.HasPrefix(fieldType.Type.Name(), "NullableRelationship[") {
511+
if m.Kind() == reflect.Ptr {
512+
m = reflect.New(fieldValue.Type().Elem().Elem())
513+
}
514+
}
495515

496516
/*
497517
http://jsonapi.org/format/#document-resource-object-relationships
@@ -500,6 +520,12 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*includ
500520
so unmarshal and set fieldValue only if data obj is not null
501521
*/
502522
if relationship.Data == nil {
523+
// Explicit null supplied for the field value
524+
// If a nullable relationship we set the field value to a map with a single entry
525+
if isExplicitNull {
526+
fieldValue.Set(reflect.MakeMapWithSize(fieldValue.Type(), 1))
527+
fieldValue.SetMapIndex(reflect.ValueOf(false), m)
528+
}
503529
continue
504530
}
505531

@@ -510,9 +536,6 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*includ
510536
continue
511537
}
512538

513-
// This will hold either the value of the choice type model or the actual
514-
// model, depending on annotation
515-
m := reflect.New(fieldValue.Type().Elem())
516539

517540
// Check if the item in the relationship was already processed elsewhere. Avoids potential infinite recursive loops
518541
// caused by circular references between included relationships (two included items include one another)
@@ -537,7 +560,12 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*includ
537560
break
538561
}
539562

540-
fieldValue.Set(m)
563+
if strings.HasPrefix(fieldType.Type.Name(), "NullableRelationship[") {
564+
fieldValue.Set(reflect.MakeMapWithSize(fieldValue.Type(), 1))
565+
fieldValue.SetMapIndex(reflect.ValueOf(true), m)
566+
} else {
567+
fieldValue.Set(m)
568+
}
541569
}
542570
} else if annotation == annotationLinks {
543571
if data.Links == nil {

Diff for: request_test.go

+122
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"io"
99
"reflect"
1010
"sort"
11+
"strconv"
1112
"strings"
1213
"testing"
1314
"time"
@@ -382,6 +383,127 @@ func TestUnmarshalNullableBool(t *testing.T) {
382383
}
383384
}
384385

386+
func TestUnmarshalNullableRelationshipsNonNullValue(t *testing.T) {
387+
comment := &Comment{
388+
ID: 5,
389+
Body: "Hello World",
390+
}
391+
392+
payload := &OnePayload{
393+
Data: &Node{
394+
ID: "10",
395+
Type: "with-nullables",
396+
Relationships: map[string]interface{}{
397+
"nullable_comment": &RelationshipOneNode{
398+
Data: &Node{
399+
Type: "comments",
400+
ID: strconv.Itoa(comment.ID),
401+
},
402+
},
403+
},
404+
},
405+
}
406+
407+
outBuf := bytes.NewBuffer(nil)
408+
json.NewEncoder(outBuf).Encode(payload)
409+
410+
out := new(WithNullableAttrs)
411+
412+
if err := UnmarshalPayload(outBuf, out); err != nil {
413+
t.Fatal(err)
414+
}
415+
416+
nullableCommentOpt := out.NullableComment
417+
if !nullableCommentOpt.IsSpecified() {
418+
t.Fatal("Expected NullableComment to be specified")
419+
}
420+
421+
nullableComment, err := nullableCommentOpt.Get()
422+
if err != nil {
423+
t.Fatal(err)
424+
}
425+
426+
if expected, actual := comment.ID, nullableComment.ID; expected != actual {
427+
t.Fatalf("Was expecting NullableComment to be `%d`, got `%d`", expected, actual)
428+
}
429+
}
430+
431+
func TestUnmarshalNullableRelationshipsExplicitNullValue(t *testing.T) {
432+
payload := &OnePayload{
433+
Data: &Node{
434+
ID: "10",
435+
Type: "with-nullables",
436+
Relationships: map[string]interface{}{
437+
"nullable_comment": &RelationshipOneNode{
438+
Data: nil,
439+
},
440+
},
441+
},
442+
}
443+
444+
outBuf := bytes.NewBuffer(nil)
445+
json.NewEncoder(outBuf).Encode(payload)
446+
447+
out := new(WithNullableAttrs)
448+
449+
if err := UnmarshalPayload(outBuf, out); err != nil {
450+
t.Fatal(err)
451+
}
452+
453+
nullableCommentOpt := out.NullableComment
454+
if !nullableCommentOpt.IsSpecified() || !nullableCommentOpt.IsNull() {
455+
t.Fatal("Expected NullableComment to be specified and explicit null")
456+
}
457+
458+
}
459+
460+
func TestUnmarshalNullableRelationshipsNonExistentValue(t *testing.T) {
461+
payload := &OnePayload{
462+
Data: &Node{
463+
ID: "10",
464+
Type: "with-nullables",
465+
Relationships: map[string]interface{}{},
466+
},
467+
}
468+
469+
outBuf := bytes.NewBuffer(nil)
470+
json.NewEncoder(outBuf).Encode(payload)
471+
472+
out := new(WithNullableAttrs)
473+
474+
if err := UnmarshalPayload(outBuf, out); err != nil {
475+
t.Fatal(err)
476+
}
477+
478+
nullableCommentOpt := out.NullableComment
479+
if nullableCommentOpt.IsSpecified() || nullableCommentOpt.IsNull() {
480+
t.Fatal("Expected NullableComment to NOT be specified and NOT be explicit null")
481+
}
482+
}
483+
484+
func TestUnmarshalNullableRelationshipsNoRelationships(t *testing.T) {
485+
payload := &OnePayload{
486+
Data: &Node{
487+
ID: "10",
488+
Type: "with-nullables",
489+
},
490+
}
491+
492+
outBuf := bytes.NewBuffer(nil)
493+
json.NewEncoder(outBuf).Encode(payload)
494+
495+
out := new(WithNullableAttrs)
496+
497+
if err := UnmarshalPayload(outBuf, out); err != nil {
498+
t.Fatal(err)
499+
}
500+
501+
nullableCommentOpt := out.NullableComment
502+
if nullableCommentOpt.IsSpecified() || nullableCommentOpt.IsNull() {
503+
t.Fatal("Expected NullableComment to NOT be specified and NOT be explicit null")
504+
}
505+
}
506+
385507
func TestMalformedTag(t *testing.T) {
386508
out := new(BadModel)
387509
err := UnmarshalPayload(samplePayload(), out)

Diff for: response.go

+22-5
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ func visitModelNodeAttribute(args []string, node *Node, fieldValue reflect.Value
253253
node.Attributes = make(map[string]interface{})
254254
}
255255

256-
// Handle Nullable[T]
256+
// Handle NullableAttr[T]
257257
if strings.HasPrefix(fieldValue.Type().Name(), "NullableAttr[") {
258258
// handle unspecified
259259
if fieldValue.IsNil() {
@@ -390,6 +390,27 @@ func visitModelNodeRelation(model any, annotation string, args []string, node *N
390390
omitEmpty = args[2] == annotationOmitEmpty
391391
}
392392

393+
if node.Relationships == nil {
394+
node.Relationships = make(map[string]interface{})
395+
}
396+
397+
// Handle NullableRelationship[T]
398+
if strings.HasPrefix(fieldValue.Type().Name(), "NullableRelationship[") {
399+
400+
if fieldValue.MapIndex(reflect.ValueOf(false)).IsValid() {
401+
innerTypeIsSlice := fieldValue.MapIndex(reflect.ValueOf(false)).Type().Kind() == reflect.Slice
402+
// handle explicit null
403+
if innerTypeIsSlice {
404+
node.Relationships[args[1]] = json.RawMessage("[]")
405+
} else {
406+
node.Relationships[args[1]] = json.RawMessage("{\"data\":null}")
407+
}
408+
} else if fieldValue.MapIndex(reflect.ValueOf(true)).IsValid() {
409+
// handle value
410+
fieldValue = fieldValue.MapIndex(reflect.ValueOf(true))
411+
}
412+
}
413+
393414
isSlice := fieldValue.Type().Kind() == reflect.Slice
394415
if omitEmpty &&
395416
(isSlice && fieldValue.Len() < 1 ||
@@ -454,10 +475,6 @@ func visitModelNodeRelation(model any, annotation string, args []string, node *N
454475
}
455476
}
456477

457-
if node.Relationships == nil {
458-
node.Relationships = make(map[string]interface{})
459-
}
460-
461478
var relLinks *Links
462479
if linkableModel, ok := model.(RelationshipLinkable); ok {
463480
relLinks = linkableModel.JSONAPIRelationshipLinks(args[1])

0 commit comments

Comments
 (0)