Skip to content

Commit

Permalink
add nullable relationship
Browse files Browse the repository at this point in the history
  • Loading branch information
netramali committed Jan 24, 2025
1 parent 0f6f733 commit a19fc77
Show file tree
Hide file tree
Showing 6 changed files with 354 additions and 13 deletions.
1 change: 1 addition & 0 deletions models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type WithNullableAttrs struct {
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 {
Expand Down
91 changes: 91 additions & 0 deletions nullable.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 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] - invalid
// - 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.
Expand Down Expand Up @@ -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 indicates 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{}
}
44 changes: 36 additions & 8 deletions request.go
Original file line number Diff line number Diff line change
Expand Up @@ -483,10 +483,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
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 {
er = fmt.Errorf("decode err %v\n", relationshipDecodeErr)
}

// 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
Expand All @@ -495,6 +515,12 @@ 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 field value to a map with a single entry
if isExplicitNull {
fieldValue.Set(reflect.MakeMapWithSize(fieldValue.Type(), 1))
fieldValue.SetMapIndex(reflect.ValueOf(false), m)
}
continue
}

Expand All @@ -505,17 +531,19 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node)
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 {
Expand Down
122 changes: 122 additions & 0 deletions request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"reflect"
"sort"
"strconv"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -382,6 +383,127 @@ 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 TestUnmarshalNullableRelationshipsExplicitNullValue(t *testing.T) {
payload := &OnePayload{
Data: &Node{
ID: "10",
Type: "with-nullables",
Relationships: map[string]interface{}{
"nullable_comment": &RelationshipOneNode{
Data: 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 be specified and 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)
Expand Down
27 changes: 22 additions & 5 deletions response.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ func visitModelNodeAttribute(args []string, node *Node, fieldValue reflect.Value
node.Attributes = make(map[string]interface{})
}

// Handle Nullable[T]
// Handle NullableAttr[T]
if strings.HasPrefix(fieldValue.Type().Name(), "NullableAttr[") {
// handle unspecified
if fieldValue.IsNil() {
Expand Down Expand Up @@ -353,6 +353,27 @@ func visitModelNodeRelation(model any, annotation string, args []string, node *N
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() {
innerTypeIsSlice := fieldValue.MapIndex(reflect.ValueOf(false)).Type().Kind() == reflect.Slice
// handle explicit null
if innerTypeIsSlice {
node.Relationships[args[1]] = json.RawMessage("[]")
} else {
node.Relationships[args[1]] = json.RawMessage("{\"data\":null}")
}
} 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 ||
Expand Down Expand Up @@ -417,10 +438,6 @@ func visitModelNodeRelation(model any, annotation string, args []string, node *N
}
}

if node.Relationships == nil {
node.Relationships = make(map[string]interface{})
}

var relLinks *Links
if linkableModel, ok := model.(RelationshipLinkable); ok {
relLinks = linkableModel.JSONAPIRelationshipLinks(args[1])
Expand Down
Loading

0 comments on commit a19fc77

Please sign in to comment.