Skip to content

Commit ec17fee

Browse files
author
Karl Kirch
committed
Add NullableRelationship support
http://jsonapi.org/format/#document-resource-object-relationships http://jsonapi.org/format/#document-resource-object-linkage Relationships can have a data node set to null (e.g. to disassociate the relationship) The NullableRelationship type allows this data to be marshalled/unmarshalled Supports slice and regular reference types
1 parent 49e11fe commit ec17fee

File tree

6 files changed

+449
-21
lines changed

6 files changed

+449
-21
lines changed

models_test.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,14 @@ type TimestampModel struct {
3636
}
3737

3838
type WithNullableAttrs struct {
39-
ID int `jsonapi:"primary,with-nullables"`
40-
Name string `jsonapi:"attr,name"`
41-
IntTime NullableAttr[time.Time] `jsonapi:"attr,int_time,omitempty"`
42-
RFC3339Time NullableAttr[time.Time] `jsonapi:"attr,rfc3339_time,rfc3339,omitempty"`
43-
ISO8601Time NullableAttr[time.Time] `jsonapi:"attr,iso8601_time,iso8601,omitempty"`
44-
Bool NullableAttr[bool] `jsonapi:"attr,bool,omitempty"`
39+
ID int `jsonapi:"primary,with-nullables"`
40+
Name string `jsonapi:"attr,name"`
41+
IntTime NullableAttr[time.Time] `jsonapi:"attr,int_time,omitempty"`
42+
RFC3339Time NullableAttr[time.Time] `jsonapi:"attr,rfc3339_time,rfc3339,omitempty"`
43+
ISO8601Time NullableAttr[time.Time] `jsonapi:"attr,iso8601_time,iso8601,omitempty"`
44+
Bool NullableAttr[bool] `jsonapi:"attr,bool,omitempty"`
45+
NullableComment NullableRelationship[*Comment] `jsonapi:"relation,nullable_comment,omitempty"`
46+
NullableComments NullableRelationship[[]*Comment] `jsonapi:"relation,nullable_comments,omitempty"`
4547
}
4648

4749
type Car struct {

nullable.go

Lines changed: 91 additions & 0 deletions
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 allowed for NullableRelationships.
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] - valid
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 indicate 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+
}

request.go

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,14 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node)
443443
// model, depending on annotation
444444
m := reflect.New(sliceType.Elem().Elem())
445445

446+
// Nullable relationships have an extra pointer indirection
447+
// unwind that here
448+
if strings.HasPrefix(fieldType.Type.Name(), "NullableRelationship[") {
449+
if m.Kind() == reflect.Ptr {
450+
m = reflect.New(sliceType.Elem().Elem().Elem())
451+
}
452+
}
453+
446454
err = unmarshalNodeMaybeChoice(&m, n, annotation, choiceMapping, included)
447455
if err != nil {
448456
er = err
@@ -459,10 +467,30 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node)
459467

460468
buf := bytes.NewBuffer(nil)
461469

462-
json.NewEncoder(buf).Encode(
463-
data.Relationships[args[1]],
464-
)
465-
json.NewDecoder(buf).Decode(relationship)
470+
relDataStr := data.Relationships[args[1]]
471+
json.NewEncoder(buf).Encode(relDataStr)
472+
473+
isExplicitNull := false
474+
if err := json.NewDecoder(buf).Decode(relationship); err != nil {
475+
// We couldn't decode the data into the relationship type
476+
// check if this is a string "null" which indicates
477+
// disassociating the relationship
478+
if relDataStr == "null" {
479+
isExplicitNull = true
480+
}
481+
}
482+
483+
// This will hold either the value of the choice type model or the actual
484+
// model, depending on annotation
485+
m := reflect.New(fieldValue.Type().Elem())
486+
487+
// Nullable relationships have an extra pointer indirection
488+
// unwind that here
489+
if strings.HasPrefix(fieldType.Type.Name(), "NullableRelationship[") {
490+
if m.Kind() == reflect.Ptr {
491+
m = reflect.New(fieldValue.Type().Elem().Elem())
492+
}
493+
}
466494

467495
/*
468496
http://jsonapi.org/format/#document-resource-object-relationships
@@ -471,20 +499,29 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node)
471499
so unmarshal and set fieldValue only if data obj is not null
472500
*/
473501
if relationship.Data == nil {
502+
503+
// Explicit null supplied for the field value
504+
// If a nullable relationship we set the
505+
if isExplicitNull && strings.HasPrefix(fieldType.Type.Name(), "NullableRelationship[") {
506+
fieldValue.Set(reflect.MakeMapWithSize(fieldValue.Type(), 1))
507+
fieldValue.SetMapIndex(reflect.ValueOf(false), m)
508+
}
509+
474510
continue
475511
}
476512

477-
// This will hold either the value of the choice type model or the actual
478-
// model, depending on annotation
479-
m := reflect.New(fieldValue.Type().Elem())
480-
481513
err = unmarshalNodeMaybeChoice(&m, relationship.Data, annotation, choiceMapping, included)
482514
if err != nil {
483515
er = err
484516
break
485517
}
486518

487-
fieldValue.Set(m)
519+
if strings.HasPrefix(fieldType.Type.Name(), "NullableRelationship[") {
520+
fieldValue.Set(reflect.MakeMapWithSize(fieldValue.Type(), 1))
521+
fieldValue.SetMapIndex(reflect.ValueOf(true), m)
522+
} else {
523+
fieldValue.Set(m)
524+
}
488525
}
489526
} else if annotation == annotationLinks {
490527
if data.Links == nil {

request_test.go

Lines changed: 146 additions & 0 deletions
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,151 @@ 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 TestUnmarshalNullableRelationshipsNullStringValue(t *testing.T) {
432+
payload := &OnePayload{
433+
Data: &Node{
434+
ID: "10",
435+
Type: "with-nullables",
436+
Relationships: map[string]interface{}{
437+
"nullable_comment": "null",
438+
},
439+
},
440+
}
441+
442+
outBuf := bytes.NewBuffer(nil)
443+
json.NewEncoder(outBuf).Encode(payload)
444+
445+
out := new(WithNullableAttrs)
446+
447+
if err := UnmarshalPayload(outBuf, out); err != nil {
448+
t.Fatal(err)
449+
}
450+
451+
nullableCommentOpt := out.NullableComment
452+
if !nullableCommentOpt.IsSpecified() || !nullableCommentOpt.IsNull() {
453+
t.Fatal("Expected NullableComment to be specified and explicit null")
454+
}
455+
456+
}
457+
458+
func TestUnmarshalNullableRelationshipsNilValue(t *testing.T) {
459+
payload := &OnePayload{
460+
Data: &Node{
461+
ID: "10",
462+
Type: "with-nullables",
463+
Relationships: map[string]interface{}{
464+
"nullable_comment": nil,
465+
},
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 TestUnmarshalNullableRelationshipsNonExistentValue(t *testing.T) {
485+
payload := &OnePayload{
486+
Data: &Node{
487+
ID: "10",
488+
Type: "with-nullables",
489+
Relationships: map[string]interface{}{},
490+
},
491+
}
492+
493+
outBuf := bytes.NewBuffer(nil)
494+
json.NewEncoder(outBuf).Encode(payload)
495+
496+
out := new(WithNullableAttrs)
497+
498+
if err := UnmarshalPayload(outBuf, out); err != nil {
499+
t.Fatal(err)
500+
}
501+
502+
nullableCommentOpt := out.NullableComment
503+
if nullableCommentOpt.IsSpecified() || nullableCommentOpt.IsNull() {
504+
t.Fatal("Expected NullableComment to NOT be specified and NOT be explicit null")
505+
}
506+
}
507+
508+
func TestUnmarshalNullableRelationshipsNoRelationships(t *testing.T) {
509+
payload := &OnePayload{
510+
Data: &Node{
511+
ID: "10",
512+
Type: "with-nullables",
513+
},
514+
}
515+
516+
outBuf := bytes.NewBuffer(nil)
517+
json.NewEncoder(outBuf).Encode(payload)
518+
519+
out := new(WithNullableAttrs)
520+
521+
if err := UnmarshalPayload(outBuf, out); err != nil {
522+
t.Fatal(err)
523+
}
524+
525+
nullableCommentOpt := out.NullableComment
526+
if nullableCommentOpt.IsSpecified() || nullableCommentOpt.IsNull() {
527+
t.Fatal("Expected NullableComment to NOT be specified and NOT be explicit null")
528+
}
529+
}
530+
385531
func TestMalformedTag(t *testing.T) {
386532
out := new(BadModel)
387533
err := UnmarshalPayload(samplePayload(), out)

0 commit comments

Comments
 (0)