Skip to content

Commit 876fb53

Browse files
authored
Merge pull request #31 from hashicorp/netramali/nullable-relationship
Add NullableRelationship support
2 parents 80e11a9 + e123c06 commit 876fb53

File tree

6 files changed

+360
-20
lines changed

6 files changed

+360
-20
lines changed

models_test.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,13 @@ 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"`
4546
}
4647

4748
type Car struct {

nullable.go

Lines changed: 92 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 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,66 @@ func (t NullableAttr[T]) IsSpecified() bool {
87116
func (t *NullableAttr[T]) SetUnspecified() {
88117
*t = map[bool]T{}
89118
}
119+
120+
// NewNullableRelationshipWithValue is a convenience helper to allow constructing a
121+
// NullableRelationship 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+
// NewNullNullableRelationship is a convenience helper to allow constructing a NullableRelationship 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+
// SetInterface sets the underlying value from an empty interface,
156+
// performing a type assertion to T.
157+
func (t *NullableRelationship[T]) SetInterface(value interface{}) {
158+
t.Set(value.(T))
159+
}
160+
161+
// IsNull indicates whether the field was sent, and had a value of `null`
162+
func (t NullableRelationship[T]) IsNull() bool {
163+
_, foundNull := t[false]
164+
return foundNull
165+
}
166+
167+
// SetNull sets the value to an explicit `null`
168+
func (t *NullableRelationship[T]) SetNull() {
169+
var empty T
170+
*t = map[bool]T{false: empty}
171+
}
172+
173+
// IsSpecified indicates whether the field was sent
174+
func (t NullableRelationship[T]) IsSpecified() bool {
175+
return len(t) != 0
176+
}
177+
178+
// SetUnspecified sets the value to be absent from the serialized payload
179+
func (t *NullableRelationship[T]) SetUnspecified() {
180+
*t = map[bool]T{}
181+
}

request.go

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -487,19 +487,43 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*includ
487487
relationship := new(RelationshipOneNode)
488488

489489
buf := bytes.NewBuffer(nil)
490+
relDataStr := data.Relationships[args[1]]
491+
json.NewEncoder(buf).Encode(relDataStr)
492+
493+
isExplicitNull := false
494+
relationshipDecodeErr := json.NewDecoder(buf).Decode(relationship)
495+
if relationshipDecodeErr == nil && relationship.Data == nil {
496+
// If the relationship was a valid node and relationship data was null
497+
// this indicates disassociating the relationship
498+
isExplicitNull = true
499+
} else if relationshipDecodeErr != nil {
500+
er = fmt.Errorf("Could not unmarshal json: %w", relationshipDecodeErr)
501+
}
490502

491-
json.NewEncoder(buf).Encode(
492-
data.Relationships[args[1]],
493-
)
494-
json.NewDecoder(buf).Decode(relationship)
503+
// This will hold either the value of the choice type model or the actual
504+
// model, depending on annotation
505+
m := reflect.New(fieldValue.Type().Elem())
495506

507+
// Nullable relationships have an extra pointer indirection
508+
// unwind that here
509+
if strings.HasPrefix(fieldType.Type.Name(), "NullableRelationship[") {
510+
if m.Kind() == reflect.Ptr {
511+
m = reflect.New(fieldValue.Type().Elem().Elem())
512+
}
513+
}
496514
/*
497515
http://jsonapi.org/format/#document-resource-object-relationships
498516
http://jsonapi.org/format/#document-resource-object-linkage
499517
relationship can have a data node set to null (e.g. to disassociate the relationship)
500518
so unmarshal and set fieldValue only if data obj is not null
501519
*/
502520
if relationship.Data == nil {
521+
// Explicit null supplied for the field value
522+
// If a nullable relationship we set the field value to a map with a single entry
523+
if isExplicitNull && strings.HasPrefix(fieldType.Type.Name(), "NullableRelationship[") {
524+
fieldValue.Set(reflect.MakeMapWithSize(fieldValue.Type(), 1))
525+
fieldValue.SetMapIndex(reflect.ValueOf(false), m)
526+
}
503527
continue
504528
}
505529

@@ -510,10 +534,6 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*includ
510534
continue
511535
}
512536

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())
516-
517537
// Check if the item in the relationship was already processed elsewhere. Avoids potential infinite recursive loops
518538
// caused by circular references between included relationships (two included items include one another)
519539
includedKey := fmt.Sprintf("%s,%s", relationship.Data.Type, relationship.Data.ID)
@@ -537,7 +557,12 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*includ
537557
break
538558
}
539559

540-
fieldValue.Set(m)
560+
if strings.HasPrefix(fieldType.Type.Name(), "NullableRelationship[") {
561+
fieldValue.Set(reflect.MakeMapWithSize(fieldValue.Type(), 1))
562+
fieldValue.SetMapIndex(reflect.ValueOf(true), m)
563+
} else {
564+
fieldValue.Set(m)
565+
}
541566
}
542567
} else if annotation == annotationLinks {
543568
if data.Links == nil {

request_test.go

Lines changed: 122 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,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)

response.go

Lines changed: 23 additions & 5 deletions
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,28 @@ 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+
return nil
409+
} else if fieldValue.MapIndex(reflect.ValueOf(true)).IsValid() {
410+
// handle value
411+
fieldValue = fieldValue.MapIndex(reflect.ValueOf(true))
412+
}
413+
}
414+
393415
isSlice := fieldValue.Type().Kind() == reflect.Slice
394416
if omitEmpty &&
395417
(isSlice && fieldValue.Len() < 1 ||
@@ -454,10 +476,6 @@ func visitModelNodeRelation(model any, annotation string, args []string, node *N
454476
}
455477
}
456478

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

0 commit comments

Comments
 (0)