From ec17fee03a4347bde17925eea81dcaf727afd8a4 Mon Sep 17 00:00:00 2001 From: Karl Kirch Date: Fri, 5 Jul 2024 14:41:31 -0500 Subject: [PATCH 1/2] 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 --- models_test.go | 14 +++-- nullable.go | 91 +++++++++++++++++++++++++++++ request.go | 55 +++++++++++++++--- request_test.go | 146 +++++++++++++++++++++++++++++++++++++++++++++++ response.go | 23 ++++++-- response_test.go | 141 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 449 insertions(+), 21 deletions(-) diff --git a/models_test.go b/models_test.go index 1b6a5ac..5346ba7 100644 --- a/models_test.go +++ b/models_test.go @@ -36,12 +36,14 @@ type TimestampModel struct { } type WithNullableAttrs struct { - ID int `jsonapi:"primary,with-nullables"` - Name string `jsonapi:"attr,name"` - IntTime NullableAttr[time.Time] `jsonapi:"attr,int_time,omitempty"` - RFC3339Time NullableAttr[time.Time] `jsonapi:"attr,rfc3339_time,rfc3339,omitempty"` - ISO8601Time NullableAttr[time.Time] `jsonapi:"attr,iso8601_time,iso8601,omitempty"` - Bool NullableAttr[bool] `jsonapi:"attr,bool,omitempty"` + ID int `jsonapi:"primary,with-nullables"` + Name string `jsonapi:"attr,name"` + IntTime NullableAttr[time.Time] `jsonapi:"attr,int_time,omitempty"` + RFC3339Time NullableAttr[time.Time] `jsonapi:"attr,rfc3339_time,rfc3339,omitempty"` + ISO8601Time NullableAttr[time.Time] `jsonapi:"attr,iso8601_time,iso8601,omitempty"` + Bool NullableAttr[bool] `jsonapi:"attr,bool,omitempty"` + NullableComment NullableRelationship[*Comment] `jsonapi:"relation,nullable_comment,omitempty"` + NullableComments NullableRelationship[[]*Comment] `jsonapi:"relation,nullable_comments,omitempty"` } type Car struct { diff --git a/nullable.go b/nullable.go index 68910f6..61bec6f 100644 --- a/nullable.go +++ b/nullable.go @@ -26,6 +26,35 @@ import ( // Adapted from https://www.jvt.me/posts/2024/01/09/go-json-nullable/ type NullableAttr[T any] map[bool]T +// NullableRelationship is a generic type, which implements a field that can be one of three states: +// +// - relationship is not set in the request +// - relationship is explicitly set to `null` in the request +// - relationship is explicitly set to a valid relationship value in the request +// +// NullableRelationship is intended to be used with JSON marshalling and unmarshalling. +// This is generally useful for PATCH requests, where relationships with zero +// values are intentionally not marshaled into the request payload so that +// existing attribute values are not overwritten. +// +// Internal implementation details: +// +// - map[true]T means a value was provided +// - map[false]T means an explicit null was provided +// - nil or zero map means the field was not provided +// +// If the relationship is expected to be optional, add the `omitempty` JSON tags. Do NOT use `*NullableRelationship`! +// +// Slice types are allowed for NullableRelationships. +// `polyrelation` JSON tags are NOT currently supported. +// +// NullableRelationships must have an inner type of pointer: +// +// - NullableRelationship[*Comment] - valid +// - NullableRelationship[[]*Comment] - valid +// - NullableRelationship[Comment] - invalid +type NullableRelationship[T any] map[bool]T + // NewNullableAttrWithValue is a convenience helper to allow constructing a // NullableAttr with a given value, for instance to construct a field inside a // struct without introducing an intermediate variable. @@ -87,3 +116,65 @@ func (t NullableAttr[T]) IsSpecified() bool { func (t *NullableAttr[T]) SetUnspecified() { *t = map[bool]T{} } + +// NewNullableAttrWithValue is a convenience helper to allow constructing a +// NullableAttr with a given value, for instance to construct a field inside a +// struct without introducing an intermediate variable. +func NewNullableRelationshipWithValue[T any](t T) NullableRelationship[T] { + var n NullableRelationship[T] + n.Set(t) + return n +} + +// NewNullNullableAttr is a convenience helper to allow constructing a NullableAttr with +// an explicit `null`, for instance to construct a field inside a struct +// without introducing an intermediate variable +func NewNullNullableRelationship[T any]() NullableRelationship[T] { + var n NullableRelationship[T] + n.SetNull() + return n +} + +// Get retrieves the underlying value, if present, and returns an error if the value was not present +func (t NullableRelationship[T]) Get() (T, error) { + var empty T + if t.IsNull() { + return empty, errors.New("value is null") + } + if !t.IsSpecified() { + return empty, errors.New("value is not specified") + } + return t[true], nil +} + +// Set sets the underlying value to a given value +func (t *NullableRelationship[T]) Set(value T) { + *t = map[bool]T{true: value} +} + +// Set sets the underlying value to a given value +func (t *NullableRelationship[T]) SetInterface(value interface{}) { + t.Set(value.(T)) +} + +// IsNull indicate whether the field was sent, and had a value of `null` +func (t NullableRelationship[T]) IsNull() bool { + _, foundNull := t[false] + return foundNull +} + +// SetNull sets the value to an explicit `null` +func (t *NullableRelationship[T]) SetNull() { + var empty T + *t = map[bool]T{false: empty} +} + +// IsSpecified indicates whether the field was sent +func (t NullableRelationship[T]) IsSpecified() bool { + return len(t) != 0 +} + +// SetUnspecified sets the value to be absent from the serialized payload +func (t *NullableRelationship[T]) SetUnspecified() { + *t = map[bool]T{} +} diff --git a/request.go b/request.go index e9ea55b..96f3007 100644 --- a/request.go +++ b/request.go @@ -443,6 +443,14 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) // model, depending on annotation m := reflect.New(sliceType.Elem().Elem()) + // Nullable relationships have an extra pointer indirection + // unwind that here + if strings.HasPrefix(fieldType.Type.Name(), "NullableRelationship[") { + if m.Kind() == reflect.Ptr { + m = reflect.New(sliceType.Elem().Elem().Elem()) + } + } + err = unmarshalNodeMaybeChoice(&m, n, annotation, choiceMapping, included) if err != nil { er = err @@ -459,10 +467,30 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) buf := bytes.NewBuffer(nil) - json.NewEncoder(buf).Encode( - data.Relationships[args[1]], - ) - json.NewDecoder(buf).Decode(relationship) + relDataStr := data.Relationships[args[1]] + json.NewEncoder(buf).Encode(relDataStr) + + isExplicitNull := false + if err := json.NewDecoder(buf).Decode(relationship); err != nil { + // We couldn't decode the data into the relationship type + // check if this is a string "null" which indicates + // disassociating the relationship + if relDataStr == "null" { + isExplicitNull = true + } + } + + // This will hold either the value of the choice type model or the actual + // model, depending on annotation + m := reflect.New(fieldValue.Type().Elem()) + + // Nullable relationships have an extra pointer indirection + // unwind that here + if strings.HasPrefix(fieldType.Type.Name(), "NullableRelationship[") { + if m.Kind() == reflect.Ptr { + m = reflect.New(fieldValue.Type().Elem().Elem()) + } + } /* http://jsonapi.org/format/#document-resource-object-relationships @@ -471,20 +499,29 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) so unmarshal and set fieldValue only if data obj is not null */ if relationship.Data == nil { + + // Explicit null supplied for the field value + // If a nullable relationship we set the + if isExplicitNull && strings.HasPrefix(fieldType.Type.Name(), "NullableRelationship[") { + fieldValue.Set(reflect.MakeMapWithSize(fieldValue.Type(), 1)) + fieldValue.SetMapIndex(reflect.ValueOf(false), m) + } + continue } - // This will hold either the value of the choice type model or the actual - // model, depending on annotation - m := reflect.New(fieldValue.Type().Elem()) - err = unmarshalNodeMaybeChoice(&m, relationship.Data, annotation, choiceMapping, included) if err != nil { er = err break } - fieldValue.Set(m) + if strings.HasPrefix(fieldType.Type.Name(), "NullableRelationship[") { + fieldValue.Set(reflect.MakeMapWithSize(fieldValue.Type(), 1)) + fieldValue.SetMapIndex(reflect.ValueOf(true), m) + } else { + fieldValue.Set(m) + } } } else if annotation == annotationLinks { if data.Links == nil { diff --git a/request_test.go b/request_test.go index 350ba6e..ab91df7 100644 --- a/request_test.go +++ b/request_test.go @@ -8,6 +8,7 @@ import ( "io" "reflect" "sort" + "strconv" "strings" "testing" "time" @@ -382,6 +383,151 @@ func TestUnmarshalNullableBool(t *testing.T) { } } +func TestUnmarshalNullableRelationshipsNonNullValue(t *testing.T) { + comment := &Comment{ + ID: 5, + Body: "Hello World", + } + + payload := &OnePayload{ + Data: &Node{ + ID: "10", + Type: "with-nullables", + Relationships: map[string]interface{}{ + "nullable_comment": &RelationshipOneNode{ + Data: &Node{ + Type: "comments", + ID: strconv.Itoa(comment.ID), + }, + }, + }, + }, + } + + outBuf := bytes.NewBuffer(nil) + json.NewEncoder(outBuf).Encode(payload) + + out := new(WithNullableAttrs) + + if err := UnmarshalPayload(outBuf, out); err != nil { + t.Fatal(err) + } + + nullableCommentOpt := out.NullableComment + if !nullableCommentOpt.IsSpecified() { + t.Fatal("Expected NullableComment to be specified") + } + + nullableComment, err := nullableCommentOpt.Get() + if err != nil { + t.Fatal(err) + } + + if expected, actual := comment.ID, nullableComment.ID; expected != actual { + t.Fatalf("Was expecting NullableComment to be `%d`, got `%d`", expected, actual) + } +} + +func TestUnmarshalNullableRelationshipsNullStringValue(t *testing.T) { + payload := &OnePayload{ + Data: &Node{ + ID: "10", + Type: "with-nullables", + Relationships: map[string]interface{}{ + "nullable_comment": "null", + }, + }, + } + + outBuf := bytes.NewBuffer(nil) + json.NewEncoder(outBuf).Encode(payload) + + out := new(WithNullableAttrs) + + if err := UnmarshalPayload(outBuf, out); err != nil { + t.Fatal(err) + } + + nullableCommentOpt := out.NullableComment + if !nullableCommentOpt.IsSpecified() || !nullableCommentOpt.IsNull() { + t.Fatal("Expected NullableComment to be specified and explicit null") + } + +} + +func TestUnmarshalNullableRelationshipsNilValue(t *testing.T) { + payload := &OnePayload{ + Data: &Node{ + ID: "10", + Type: "with-nullables", + Relationships: map[string]interface{}{ + "nullable_comment": nil, + }, + }, + } + + outBuf := bytes.NewBuffer(nil) + json.NewEncoder(outBuf).Encode(payload) + + out := new(WithNullableAttrs) + + if err := UnmarshalPayload(outBuf, out); err != nil { + t.Fatal(err) + } + + nullableCommentOpt := out.NullableComment + if nullableCommentOpt.IsSpecified() || nullableCommentOpt.IsNull() { + t.Fatal("Expected NullableComment to NOT be specified and NOT be explicit null") + } +} + +func TestUnmarshalNullableRelationshipsNonExistentValue(t *testing.T) { + payload := &OnePayload{ + Data: &Node{ + ID: "10", + Type: "with-nullables", + Relationships: map[string]interface{}{}, + }, + } + + outBuf := bytes.NewBuffer(nil) + json.NewEncoder(outBuf).Encode(payload) + + out := new(WithNullableAttrs) + + if err := UnmarshalPayload(outBuf, out); err != nil { + t.Fatal(err) + } + + nullableCommentOpt := out.NullableComment + if nullableCommentOpt.IsSpecified() || nullableCommentOpt.IsNull() { + t.Fatal("Expected NullableComment to NOT be specified and NOT be explicit null") + } +} + +func TestUnmarshalNullableRelationshipsNoRelationships(t *testing.T) { + payload := &OnePayload{ + Data: &Node{ + ID: "10", + Type: "with-nullables", + }, + } + + outBuf := bytes.NewBuffer(nil) + json.NewEncoder(outBuf).Encode(payload) + + out := new(WithNullableAttrs) + + if err := UnmarshalPayload(outBuf, out); err != nil { + t.Fatal(err) + } + + nullableCommentOpt := out.NullableComment + if nullableCommentOpt.IsSpecified() || nullableCommentOpt.IsNull() { + t.Fatal("Expected NullableComment to NOT be specified and NOT be explicit null") + } +} + func TestMalformedTag(t *testing.T) { out := new(BadModel) err := UnmarshalPayload(samplePayload(), out) diff --git a/response.go b/response.go index dea77d8..120dd44 100644 --- a/response.go +++ b/response.go @@ -331,7 +331,7 @@ func visitModelNode(model interface{}, included *map[string]*Node, node.Attributes = make(map[string]interface{}) } - // Handle Nullable[T] + // Handle NullableAttr[T] if strings.HasPrefix(fieldValue.Type().Name(), "NullableAttr[") { // handle unspecified if fieldValue.IsNil() { @@ -343,7 +343,6 @@ func visitModelNode(model interface{}, included *map[string]*Node, node.Attributes[args[1]] = json.RawMessage("null") continue } else { - // handle value fieldValue = fieldValue.MapIndex(reflect.ValueOf(true)) } @@ -410,6 +409,22 @@ func visitModelNode(model interface{}, included *map[string]*Node, omitEmpty = args[2] == annotationOmitEmpty } + if node.Relationships == nil { + node.Relationships = make(map[string]interface{}) + } + + // Handle NullableRelationship[T] + if strings.HasPrefix(fieldValue.Type().Name(), "NullableRelationship[") { + if fieldValue.MapIndex(reflect.ValueOf(false)).IsValid() { + // handle explicit null + node.Relationships[args[1]] = json.RawMessage("null") + continue + } else if fieldValue.MapIndex(reflect.ValueOf(true)).IsValid() { + // handle value + fieldValue = fieldValue.MapIndex(reflect.ValueOf(true)) + } + } + isSlice := fieldValue.Type().Kind() == reflect.Slice if omitEmpty && (isSlice && fieldValue.Len() < 1 || @@ -481,10 +496,6 @@ func visitModelNode(model interface{}, included *map[string]*Node, } } - if node.Relationships == nil { - node.Relationships = make(map[string]interface{}) - } - var relLinks *Links if linkableModel, ok := model.(RelationshipLinkable); ok { relLinks = linkableModel.JSONAPIRelationshipLinks(args[1]) diff --git a/response_test.go b/response_test.go index d79d64f..e4c274e 100644 --- a/response_test.go +++ b/response_test.go @@ -6,6 +6,7 @@ import ( "fmt" "reflect" "sort" + "strconv" "testing" "time" ) @@ -987,6 +988,146 @@ func TestNullableAttr_Bool(t *testing.T) { } } +func TestNullableRelationship(t *testing.T) { + comment := &Comment{ + ID: 5, + Body: "Hello World", + } + + comments := []*Comment{ + { + ID: 6, + Body: "Hello World", + }, + { + ID: 7, + Body: "Hello World", + }, + } + + for _, tc := range []struct { + desc string + input *WithNullableAttrs + verification func(data map[string]interface{}) error + }{ + { + desc: "nullable_comment_unspecified", + input: &WithNullableAttrs{ + ID: 5, + NullableComment: nil, + }, + verification: func(root map[string]interface{}) error { + _, ok := root["data"].(map[string]interface{})["relationships"] + + if got, want := ok, false; got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "nullable_comment_null", + input: &WithNullableAttrs{ + ID: 5, + NullableComment: NewNullNullableRelationship[*Comment](), + }, + verification: func(root map[string]interface{}) error { + _, ok := root["data"].(map[string]interface{})["relationships"].(map[string]interface{})["nullable_comment"] + + if got, want := ok, true; got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "nullable_comment_not_null", + input: &WithNullableAttrs{ + ID: 5, + NullableComment: NewNullableRelationshipWithValue(comment), + }, + verification: func(root map[string]interface{}) error { + relationships := root["data"].(map[string]interface{})["relationships"] + nullableComment := relationships.(map[string]interface{})["nullable_comment"] + idStr := nullableComment.(map[string]interface{})["data"].(map[string]interface{})["id"].(string) + id, _ := strconv.Atoi(idStr) + if got, want := id, comment.ID; got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "nullable_comments_unspecified", + input: &WithNullableAttrs{ + ID: 5, + NullableComments: nil, + }, + verification: func(root map[string]interface{}) error { + _, ok := root["data"].(map[string]interface{})["relationships"] + + if got, want := ok, false; got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "nullable_comments_null", + input: &WithNullableAttrs{ + ID: 5, + NullableComments: NewNullNullableRelationship[[]*Comment](), + }, + verification: func(root map[string]interface{}) error { + _, ok := root["data"].(map[string]interface{})["relationships"].(map[string]interface{})["nullable_comments"] + + if got, want := ok, true; got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "nullable_comments_not_null", + input: &WithNullableAttrs{ + ID: 5, + NullableComments: NewNullableRelationshipWithValue(comments), + }, + verification: func(root map[string]interface{}) error { + relationships := root["data"].(map[string]interface{})["relationships"] + nullableComments := relationships.(map[string]interface{})["nullable_comments"].(map[string]interface{})["data"].([]interface{}) + + for i := 0; i < len(comments); i++ { + c := nullableComments[i].(map[string]interface{}) + idStr := c["id"].(string) + id, _ := strconv.Atoi(idStr) + if got, want := id, comments[i].ID; got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + } + + return nil + }, + }, + } { + t.Run(tc.desc, func(t *testing.T) { + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, tc.input); err != nil { + t.Fatal(err) + } + + // Use the standard JSON library to traverse the genereated JSON payload. + data := map[string]interface{}{} + json.Unmarshal(out.Bytes(), &data) + if tc.verification != nil { + if err := tc.verification(data); err != nil { + t.Fatal(err) + } + } + }) + } +} + func TestSupportsLinkable(t *testing.T) { testModel := &Blog{ ID: 5, From 4dedb19ba93f0c4d92a446ad8759ff2586b0659b Mon Sep 17 00:00:00 2001 From: Karl Kirch Date: Tue, 9 Jul 2024 10:12:54 -0500 Subject: [PATCH 2/2] Remove support for NullableRelationship[[]*...] slice types --- models_test.go | 15 +++++------ nullable.go | 4 +-- request.go | 22 +++++---------- request_test.go | 32 +++------------------- response.go | 8 +++++- response_test.go | 69 ++++-------------------------------------------- 6 files changed, 32 insertions(+), 118 deletions(-) diff --git a/models_test.go b/models_test.go index 5346ba7..99a7f32 100644 --- a/models_test.go +++ b/models_test.go @@ -36,14 +36,13 @@ type TimestampModel struct { } type WithNullableAttrs struct { - ID int `jsonapi:"primary,with-nullables"` - Name string `jsonapi:"attr,name"` - IntTime NullableAttr[time.Time] `jsonapi:"attr,int_time,omitempty"` - RFC3339Time NullableAttr[time.Time] `jsonapi:"attr,rfc3339_time,rfc3339,omitempty"` - ISO8601Time NullableAttr[time.Time] `jsonapi:"attr,iso8601_time,iso8601,omitempty"` - Bool NullableAttr[bool] `jsonapi:"attr,bool,omitempty"` - NullableComment NullableRelationship[*Comment] `jsonapi:"relation,nullable_comment,omitempty"` - NullableComments NullableRelationship[[]*Comment] `jsonapi:"relation,nullable_comments,omitempty"` + ID int `jsonapi:"primary,with-nullables"` + Name string `jsonapi:"attr,name"` + IntTime NullableAttr[time.Time] `jsonapi:"attr,int_time,omitempty"` + RFC3339Time NullableAttr[time.Time] `jsonapi:"attr,rfc3339_time,rfc3339,omitempty"` + ISO8601Time NullableAttr[time.Time] `jsonapi:"attr,iso8601_time,iso8601,omitempty"` + Bool NullableAttr[bool] `jsonapi:"attr,bool,omitempty"` + NullableComment NullableRelationship[*Comment] `jsonapi:"relation,nullable_comment,omitempty"` } type Car struct { diff --git a/nullable.go b/nullable.go index 61bec6f..7bf2c0c 100644 --- a/nullable.go +++ b/nullable.go @@ -45,13 +45,13 @@ type NullableAttr[T any] map[bool]T // // If the relationship is expected to be optional, add the `omitempty` JSON tags. Do NOT use `*NullableRelationship`! // -// Slice types are allowed for NullableRelationships. +// Slice types are not currently supported for NullableRelationships as the nullable nature can be expressed via empty array // `polyrelation` JSON tags are NOT currently supported. // // NullableRelationships must have an inner type of pointer: // // - NullableRelationship[*Comment] - valid -// - NullableRelationship[[]*Comment] - valid +// - NullableRelationship[[]*Comment] - invalid // - NullableRelationship[Comment] - invalid type NullableRelationship[T any] map[bool]T diff --git a/request.go b/request.go index 96f3007..708a6ea 100644 --- a/request.go +++ b/request.go @@ -443,14 +443,6 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) // model, depending on annotation m := reflect.New(sliceType.Elem().Elem()) - // Nullable relationships have an extra pointer indirection - // unwind that here - if strings.HasPrefix(fieldType.Type.Name(), "NullableRelationship[") { - if m.Kind() == reflect.Ptr { - m = reflect.New(sliceType.Elem().Elem().Elem()) - } - } - err = unmarshalNodeMaybeChoice(&m, n, annotation, choiceMapping, included) if err != nil { er = err @@ -471,13 +463,13 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) json.NewEncoder(buf).Encode(relDataStr) isExplicitNull := false - if err := json.NewDecoder(buf).Decode(relationship); err != nil { - // We couldn't decode the data into the relationship type - // check if this is a string "null" which indicates - // disassociating the relationship - if relDataStr == "null" { - isExplicitNull = true - } + relationshipDecodeErr := json.NewDecoder(buf).Decode(relationship) + if relationshipDecodeErr == nil && relationship.Data == nil { + // If the relationship was a valid node and relationship data was null + // this indicates disassociating the relationship + isExplicitNull = true + } else if relationshipDecodeErr != nil { + fmt.Printf("decode err %v\n", relationshipDecodeErr) } // This will hold either the value of the choice type model or the actual diff --git a/request_test.go b/request_test.go index ab91df7..a5e5f22 100644 --- a/request_test.go +++ b/request_test.go @@ -428,13 +428,15 @@ func TestUnmarshalNullableRelationshipsNonNullValue(t *testing.T) { } } -func TestUnmarshalNullableRelationshipsNullStringValue(t *testing.T) { +func TestUnmarshalNullableRelationshipsExplicitNullValue(t *testing.T) { payload := &OnePayload{ Data: &Node{ ID: "10", Type: "with-nullables", Relationships: map[string]interface{}{ - "nullable_comment": "null", + "nullable_comment": &RelationshipOneNode{ + Data: nil, + }, }, }, } @@ -455,32 +457,6 @@ func TestUnmarshalNullableRelationshipsNullStringValue(t *testing.T) { } -func TestUnmarshalNullableRelationshipsNilValue(t *testing.T) { - payload := &OnePayload{ - Data: &Node{ - ID: "10", - Type: "with-nullables", - Relationships: map[string]interface{}{ - "nullable_comment": nil, - }, - }, - } - - outBuf := bytes.NewBuffer(nil) - json.NewEncoder(outBuf).Encode(payload) - - out := new(WithNullableAttrs) - - if err := UnmarshalPayload(outBuf, out); err != nil { - t.Fatal(err) - } - - nullableCommentOpt := out.NullableComment - if nullableCommentOpt.IsSpecified() || nullableCommentOpt.IsNull() { - t.Fatal("Expected NullableComment to NOT be specified and NOT be explicit null") - } -} - func TestUnmarshalNullableRelationshipsNonExistentValue(t *testing.T) { payload := &OnePayload{ Data: &Node{ diff --git a/response.go b/response.go index 120dd44..64b97c3 100644 --- a/response.go +++ b/response.go @@ -415,9 +415,15 @@ func visitModelNode(model interface{}, included *map[string]*Node, // Handle NullableRelationship[T] if strings.HasPrefix(fieldValue.Type().Name(), "NullableRelationship[") { + if fieldValue.MapIndex(reflect.ValueOf(false)).IsValid() { + innerTypeIsSlice := fieldValue.MapIndex(reflect.ValueOf(false)).Type().Kind() == reflect.Slice // handle explicit null - node.Relationships[args[1]] = json.RawMessage("null") + if innerTypeIsSlice { + node.Relationships[args[1]] = json.RawMessage("[]") + } else { + node.Relationships[args[1]] = json.RawMessage("{\"data\":null}") + } continue } else if fieldValue.MapIndex(reflect.ValueOf(true)).IsValid() { // handle value diff --git a/response_test.go b/response_test.go index e4c274e..dbbf0e9 100644 --- a/response_test.go +++ b/response_test.go @@ -994,17 +994,6 @@ func TestNullableRelationship(t *testing.T) { Body: "Hello World", } - comments := []*Comment{ - { - ID: 6, - Body: "Hello World", - }, - { - ID: 7, - Body: "Hello World", - }, - } - for _, tc := range []struct { desc string input *WithNullableAttrs @@ -1032,11 +1021,15 @@ func TestNullableRelationship(t *testing.T) { NullableComment: NewNullNullableRelationship[*Comment](), }, verification: func(root map[string]interface{}) error { - _, ok := root["data"].(map[string]interface{})["relationships"].(map[string]interface{})["nullable_comment"] + commentData, ok := root["data"].(map[string]interface{})["relationships"].(map[string]interface{})["nullable_comment"].(map[string]interface{})["data"] if got, want := ok, true; got != want { return fmt.Errorf("got %v, want %v", got, want) } + + if commentData != nil { + return fmt.Errorf("Expected nil data for nullable_comment but was '%v'", commentData) + } return nil }, }, @@ -1057,58 +1050,6 @@ func TestNullableRelationship(t *testing.T) { return nil }, }, - { - desc: "nullable_comments_unspecified", - input: &WithNullableAttrs{ - ID: 5, - NullableComments: nil, - }, - verification: func(root map[string]interface{}) error { - _, ok := root["data"].(map[string]interface{})["relationships"] - - if got, want := ok, false; got != want { - return fmt.Errorf("got %v, want %v", got, want) - } - return nil - }, - }, - { - desc: "nullable_comments_null", - input: &WithNullableAttrs{ - ID: 5, - NullableComments: NewNullNullableRelationship[[]*Comment](), - }, - verification: func(root map[string]interface{}) error { - _, ok := root["data"].(map[string]interface{})["relationships"].(map[string]interface{})["nullable_comments"] - - if got, want := ok, true; got != want { - return fmt.Errorf("got %v, want %v", got, want) - } - return nil - }, - }, - { - desc: "nullable_comments_not_null", - input: &WithNullableAttrs{ - ID: 5, - NullableComments: NewNullableRelationshipWithValue(comments), - }, - verification: func(root map[string]interface{}) error { - relationships := root["data"].(map[string]interface{})["relationships"] - nullableComments := relationships.(map[string]interface{})["nullable_comments"].(map[string]interface{})["data"].([]interface{}) - - for i := 0; i < len(comments); i++ { - c := nullableComments[i].(map[string]interface{}) - idStr := c["id"].(string) - id, _ := strconv.Atoi(idStr) - if got, want := id, comments[i].ID; got != want { - return fmt.Errorf("got %v, want %v", got, want) - } - } - - return nil - }, - }, } { t.Run(tc.desc, func(t *testing.T) { out := bytes.NewBuffer(nil)