From f0bb6b35535e320d603ddfed9f7e5f168665e3a4 Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Sat, 13 Mar 2021 19:39:39 +0000 Subject: [PATCH 01/28] fix(constants): typo in annotationSeparator --- constants.go | 2 +- response.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/constants.go b/constants.go index 23288d31..0b1a33cd 100644 --- a/constants.go +++ b/constants.go @@ -9,7 +9,7 @@ const ( annotationRelation = "relation" annotationOmitEmpty = "omitempty" annotationISO8601 = "iso8601" - annotationSeperator = "," + annotationSeparator = "," iso8601TimeFormat = "2006-01-02T15:04:05Z" diff --git a/response.go b/response.go index 3f8ab73d..b31c1bb3 100644 --- a/response.go +++ b/response.go @@ -215,7 +215,7 @@ func visitModelNode(model interface{}, included *map[string]*Node, fieldValue := modelValue.Field(i) fieldType := modelType.Field(i) - args := strings.Split(tag, annotationSeperator) + args := strings.Split(tag, annotationSeparator) if len(args) < 1 { er = ErrBadJSONAPIStructTag From 047fce9dcee50070849359af54298615b1f92a1b Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Sun, 14 Mar 2021 10:33:04 +0000 Subject: [PATCH 02/28] refactor(response): split the visitModelNode() logic to make the code more manageable - three new functions were created: resolveNodeID(), resolveNodeAttribute() and resolveNodeRelation() --- response.go | 427 +++++++++++++++++++++++++++------------------------- 1 file changed, 223 insertions(+), 204 deletions(-) diff --git a/response.go b/response.go index b31c1bb3..7336f726 100644 --- a/response.go +++ b/response.go @@ -196,7 +196,6 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool) (*Node, error) { node := new(Node) - var er error value := reflect.ValueOf(model) if value.IsNil() { return nil, nil @@ -218,250 +217,270 @@ func visitModelNode(model interface{}, included *map[string]*Node, args := strings.Split(tag, annotationSeparator) if len(args) < 1 { - er = ErrBadJSONAPIStructTag - break + return nil, ErrBadJSONAPIStructTag } annotation := args[0] if (annotation == annotationClientID && len(args) != 1) || (annotation != annotationClientID && len(args) < 2) { - er = ErrBadJSONAPIStructTag - break + return nil, ErrBadJSONAPIStructTag } - if annotation == annotationPrimary { - v := fieldValue + var err error - // Deal with PTRS - var kind reflect.Kind - if fieldValue.Kind() == reflect.Ptr { - kind = fieldType.Type.Elem().Kind() - v = reflect.Indirect(fieldValue) - } else { - kind = fieldType.Type.Kind() - } - - // Handle allowed types - switch kind { - case reflect.String: - node.ID = v.Interface().(string) - case reflect.Int: - node.ID = strconv.FormatInt(int64(v.Interface().(int)), 10) - case reflect.Int8: - node.ID = strconv.FormatInt(int64(v.Interface().(int8)), 10) - case reflect.Int16: - node.ID = strconv.FormatInt(int64(v.Interface().(int16)), 10) - case reflect.Int32: - node.ID = strconv.FormatInt(int64(v.Interface().(int32)), 10) - case reflect.Int64: - node.ID = strconv.FormatInt(v.Interface().(int64), 10) - case reflect.Uint: - node.ID = strconv.FormatUint(uint64(v.Interface().(uint)), 10) - case reflect.Uint8: - node.ID = strconv.FormatUint(uint64(v.Interface().(uint8)), 10) - case reflect.Uint16: - node.ID = strconv.FormatUint(uint64(v.Interface().(uint16)), 10) - case reflect.Uint32: - node.ID = strconv.FormatUint(uint64(v.Interface().(uint32)), 10) - case reflect.Uint64: - node.ID = strconv.FormatUint(v.Interface().(uint64), 10) - default: - // We had a JSON float (numeric), but our field was not one of the - // allowed numeric types - er = ErrBadJSONAPIID - } + switch annotation { + case annotationPrimary: + node, err = resolveNodeID(node, fieldValue, fieldType) - if er != nil { - break + if err != nil { + return nil, err } node.Type = args[1] - } else if annotation == annotationClientID { + case annotationClientID: clientID := fieldValue.String() if clientID != "" { node.ClientID = clientID } - } else if annotation == annotationAttribute { - var omitEmpty, iso8601 bool - - if len(args) > 2 { - for _, arg := range args[2:] { - switch arg { - case annotationOmitEmpty: - omitEmpty = true - case annotationISO8601: - iso8601 = true - } - } - } + case annotationAttribute: + node = resolveNodeAttribute(node, fieldValue, args) + case annotationRelation: + node, err = resolveNodeRelation(node, fieldValue, args, model, included, sideload) - if node.Attributes == nil { - node.Attributes = make(map[string]interface{}) + if err != nil { + return nil, err } + default: + return nil, ErrBadJSONAPIStructTag + } + } - if fieldValue.Type() == reflect.TypeOf(time.Time{}) { - t := fieldValue.Interface().(time.Time) - - if t.IsZero() { - continue - } - - if iso8601 { - node.Attributes[args[1]] = t.UTC().Format(iso8601TimeFormat) - } else { - node.Attributes[args[1]] = t.Unix() - } - } else if fieldValue.Type() == reflect.TypeOf(new(time.Time)) { - // A time pointer may be nil - if fieldValue.IsNil() { - if omitEmpty { - continue - } - - node.Attributes[args[1]] = nil - } else { - tm := fieldValue.Interface().(*time.Time) - - if tm.IsZero() && omitEmpty { - continue - } - - if iso8601 { - node.Attributes[args[1]] = tm.UTC().Format(iso8601TimeFormat) - } else { - node.Attributes[args[1]] = tm.Unix() - } - } - } else { - // Dealing with a fieldValue that is not a time - emptyValue := reflect.Zero(fieldValue.Type()) - - // See if we need to omit this field - if omitEmpty && reflect.DeepEqual(fieldValue.Interface(), emptyValue.Interface()) { - continue - } - - strAttr, ok := fieldValue.Interface().(string) - if ok { - node.Attributes[args[1]] = strAttr - } else { - node.Attributes[args[1]] = fieldValue.Interface() - } - } - } else if annotation == annotationRelation { - var omitEmpty bool + if linkableModel, isLinkable := model.(Linkable); isLinkable { + jl := linkableModel.JSONAPILinks() + if er := jl.validate(); er != nil { + return nil, er + } + node.Links = linkableModel.JSONAPILinks() + } - //add support for 'omitempty' struct tag for marshaling as absent - if len(args) > 2 { - omitEmpty = args[2] == annotationOmitEmpty - } + if metableModel, ok := model.(Metable); ok { + node.Meta = metableModel.JSONAPIMeta() + } - isSlice := fieldValue.Type().Kind() == reflect.Slice - if omitEmpty && - (isSlice && fieldValue.Len() < 1 || - (!isSlice && fieldValue.IsNil())) { - continue - } + return node, nil +} + +func resolveNodeID(node *Node, fieldValue reflect.Value, fieldType reflect.StructField) (*Node, error) { + v := fieldValue + + // Deal with PTRS + var kind reflect.Kind + if fieldValue.Kind() == reflect.Ptr { + kind = fieldType.Type.Elem().Kind() + v = reflect.Indirect(fieldValue) + } else { + kind = fieldType.Type.Kind() + } + + // Handle allowed types + switch kind { + case reflect.String: + node.ID = v.Interface().(string) + case reflect.Int: + node.ID = strconv.FormatInt(int64(v.Interface().(int)), 10) + case reflect.Int8: + node.ID = strconv.FormatInt(int64(v.Interface().(int8)), 10) + case reflect.Int16: + node.ID = strconv.FormatInt(int64(v.Interface().(int16)), 10) + case reflect.Int32: + node.ID = strconv.FormatInt(int64(v.Interface().(int32)), 10) + case reflect.Int64: + node.ID = strconv.FormatInt(v.Interface().(int64), 10) + case reflect.Uint: + node.ID = strconv.FormatUint(uint64(v.Interface().(uint)), 10) + case reflect.Uint8: + node.ID = strconv.FormatUint(uint64(v.Interface().(uint8)), 10) + case reflect.Uint16: + node.ID = strconv.FormatUint(uint64(v.Interface().(uint16)), 10) + case reflect.Uint32: + node.ID = strconv.FormatUint(uint64(v.Interface().(uint32)), 10) + case reflect.Uint64: + node.ID = strconv.FormatUint(v.Interface().(uint64), 10) + default: + // We had a JSON float (numeric), but our field was not one of the + // allowed numeric types + return nil, ErrBadJSONAPIID + } + + return node, nil +} + +func resolveNodeAttribute(node *Node, fieldValue reflect.Value, args []string) *Node { + var omitEmpty, iso8601 bool - if node.Relationships == nil { - node.Relationships = make(map[string]interface{}) + if len(args) > 2 { + for _, arg := range args[2:] { + switch arg { + case annotationOmitEmpty: + omitEmpty = true + case annotationISO8601: + iso8601 = true } + } + } + + if node.Attributes == nil { + node.Attributes = make(map[string]interface{}) + } + + switch fieldValue.Type() { + case reflect.TypeOf(time.Time{}): + t := fieldValue.Interface().(time.Time) - var relLinks *Links - if linkableModel, ok := model.(RelationshipLinkable); ok { - relLinks = linkableModel.JSONAPIRelationshipLinks(args[1]) + if t.IsZero() { + return node + } + + if iso8601 { + node.Attributes[args[1]] = t.UTC().Format(iso8601TimeFormat) + } else { + node.Attributes[args[1]] = t.Unix() + } + case reflect.TypeOf(new(time.Time)): + // A time pointer may be nil + if fieldValue.IsNil() { + if omitEmpty { + return node } - var relMeta *Meta - if metableModel, ok := model.(RelationshipMetable); ok { - relMeta = metableModel.JSONAPIRelationshipMeta(args[1]) + node.Attributes[args[1]] = nil + } else { + t := fieldValue.Interface().(*time.Time) + + if t.IsZero() && omitEmpty { + return node } - if isSlice { - // to-many relationship - relationship, err := visitModelNodeRelationships( - fieldValue, - included, - sideload, - ) - if err != nil { - er = err - break - } - relationship.Links = relLinks - relationship.Meta = relMeta - - if sideload { - shallowNodes := []*Node{} - for _, n := range relationship.Data { - appendIncluded(included, n) - shallowNodes = append(shallowNodes, toShallowNode(n)) - } - - node.Relationships[args[1]] = &RelationshipManyNode{ - Data: shallowNodes, - Links: relationship.Links, - Meta: relationship.Meta, - } - } else { - node.Relationships[args[1]] = relationship - } + if iso8601 { + node.Attributes[args[1]] = t.UTC().Format(iso8601TimeFormat) } else { - // to-one relationships - - // Handle null relationship case - if fieldValue.IsNil() { - node.Relationships[args[1]] = &RelationshipOneNode{Data: nil} - continue - } - - relationship, err := visitModelNode( - fieldValue.Interface(), - included, - sideload, - ) - if err != nil { - er = err - break - } - - if sideload { - appendIncluded(included, relationship) - node.Relationships[args[1]] = &RelationshipOneNode{ - Data: toShallowNode(relationship), - Links: relLinks, - Meta: relMeta, - } - } else { - node.Relationships[args[1]] = &RelationshipOneNode{ - Data: relationship, - Links: relLinks, - Meta: relMeta, - } - } + node.Attributes[args[1]] = t.Unix() } + } + default: + // Dealing with a fieldValue that is not a time + emptyValue := reflect.Zero(fieldValue.Type()) + // See if we need to omit this field + if omitEmpty && reflect.DeepEqual(fieldValue.Interface(), emptyValue.Interface()) { + return node + } + + if str, ok := fieldValue.Interface().(string); ok { + node.Attributes[args[1]] = str } else { - er = ErrBadJSONAPIStructTag - break + node.Attributes[args[1]] = fieldValue.Interface() } } - if er != nil { - return nil, er + return node +} + +func resolveNodeRelation(node *Node, fieldValue reflect.Value, args []string, + model interface{}, included *map[string]*Node, sideload bool) (*Node, error) { + var omitEmpty bool + + // add support for 'omitempty' struct tag for marshaling as absent + if len(args) > 2 { + omitEmpty = args[2] == annotationOmitEmpty } - if linkableModel, isLinkable := model.(Linkable); isLinkable { - jl := linkableModel.JSONAPILinks() - if er := jl.validate(); er != nil { - return nil, er + isSlice := fieldValue.Type().Kind() == reflect.Slice + if omitEmpty && + (isSlice && fieldValue.Len() < 1 || + (!isSlice && fieldValue.IsNil())) { + return node, nil + } + + if node.Relationships == nil { + node.Relationships = make(map[string]interface{}) + } + + var relLinks *Links + if linkableModel, ok := model.(RelationshipLinkable); ok { + relLinks = linkableModel.JSONAPIRelationshipLinks(args[1]) + } + + var relMeta *Meta + if metableModel, ok := model.(RelationshipMetable); ok { + relMeta = metableModel.JSONAPIRelationshipMeta(args[1]) + } + + if isSlice { + // to-many relationship + relationship, err := visitModelNodeRelationships( + fieldValue, + included, + sideload, + ) + if err != nil { + return nil, err } - node.Links = linkableModel.JSONAPILinks() + + relationship.Links = relLinks + relationship.Meta = relMeta + + if sideload { + shallowNodes := []*Node{} + for _, n := range relationship.Data { + appendIncluded(included, n) + shallowNodes = append(shallowNodes, toShallowNode(n)) + } + + node.Relationships[args[1]] = &RelationshipManyNode{ + Data: shallowNodes, + Links: relationship.Links, + Meta: relationship.Meta, + } + } else { + node.Relationships[args[1]] = relationship + } + + return node, nil } - if metableModel, ok := model.(Metable); ok { - node.Meta = metableModel.JSONAPIMeta() + // to-one relationships + + // Handle null relationship case + if fieldValue.IsNil() { + node.Relationships[args[1]] = &RelationshipOneNode{Data: nil} + + return node, nil + } + + relationship, err := visitModelNode( + fieldValue.Interface(), + included, + sideload, + ) + if err != nil { + return nil, err + } + + if sideload { + appendIncluded(included, relationship) + node.Relationships[args[1]] = &RelationshipOneNode{ + Data: toShallowNode(relationship), + Links: relLinks, + Meta: relMeta, + } + } else { + node.Relationships[args[1]] = &RelationshipOneNode{ + Data: relationship, + Links: relLinks, + Meta: relMeta, + } } return node, nil From 4443a1fc51b1337d829508b16ace6f08d65841f4 Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Sun, 14 Mar 2021 10:37:09 +0000 Subject: [PATCH 03/28] feat(response): allow resolving a node ID from an sql.NullString, sql.NullInt32 and sql.NullInt64 type --- response.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/response.go b/response.go index 7336f726..133c5529 100644 --- a/response.go +++ b/response.go @@ -1,6 +1,7 @@ package jsonapi import ( + "database/sql" "encoding/json" "errors" "fmt" @@ -307,6 +308,23 @@ func resolveNodeID(node *Node, fieldValue reflect.Value, fieldType reflect.Struc node.ID = strconv.FormatUint(uint64(v.Interface().(uint32)), 10) case reflect.Uint64: node.ID = strconv.FormatUint(v.Interface().(uint64), 10) + case reflect.Struct: + if nStr, ok := v.Interface().(sql.NullString); ok { + node.ID = nStr.String + break + } + + if nI32, ok := v.Interface().(sql.NullInt32); ok { + node.ID = strconv.FormatInt(int64(nI32.Int32), 10) + break + } + + if nI64, ok := v.Interface().(sql.NullInt64); ok { + node.ID = strconv.FormatInt(nI64.Int64, 10) + break + } + + fallthrough default: // We had a JSON float (numeric), but our field was not one of the // allowed numeric types From 2da336f3fe3223ebfa1fba75193f0e3243107edb Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Sun, 14 Mar 2021 10:38:45 +0000 Subject: [PATCH 04/28] feat(response): add support for the sql.NullTime type --- response.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/response.go b/response.go index 133c5529..3a62fcf4 100644 --- a/response.go +++ b/response.go @@ -386,6 +386,27 @@ func resolveNodeAttribute(node *Node, fieldValue reflect.Value, args []string) * node.Attributes[args[1]] = t.Unix() } } + case reflect.TypeOf(sql.NullTime{}): + nt := fieldValue.Interface().(sql.NullTime) + + // Time is NULL + if !nt.Valid { + if omitEmpty { + return node + } + + node.Attributes[args[1]] = nil + } else { + if nt.Time.IsZero() && omitEmpty { + return node + } + + if iso8601 { + node.Attributes[args[1]] = nt.Time.UTC().Format(iso8601TimeFormat) + } else { + node.Attributes[args[1]] = nt.Time.Unix() + } + } default: // Dealing with a fieldValue that is not a time emptyValue := reflect.Zero(fieldValue.Type()) From 7157d3fb3a09a62016417e19145dda93f4b17053 Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Sun, 14 Mar 2021 10:40:58 +0000 Subject: [PATCH 05/28] feat(response): add attribute support for the remaining sql null types: - sql.NullBool - sql.NullString - sql.NullFloat64 - sql.NullInt32 - sql.NullInt64 --- response.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/response.go b/response.go index 3a62fcf4..d4439dc6 100644 --- a/response.go +++ b/response.go @@ -416,6 +416,33 @@ func resolveNodeAttribute(node *Node, fieldValue reflect.Value, args []string) * return node } + // Deal with database/sql null types + if nBool, ok := fieldValue.Interface().(sql.NullBool); ok { + node.Attributes[args[1]] = nBool.Bool + break + } + + if nStr, ok := fieldValue.Interface().(sql.NullString); ok { + node.Attributes[args[1]] = nStr.String + break + } + + if nF64, ok := fieldValue.Interface().(sql.NullFloat64); ok { + node.Attributes[args[1]] = nF64.Float64 + break + } + + if nI32, ok := fieldValue.Interface().(sql.NullInt32); ok { + node.Attributes[args[1]] = nI32.Int32 + break + } + + if nI64, ok := fieldValue.Interface().(sql.NullInt64); ok { + node.Attributes[args[1]] = nI64.Int64 + break + } + + // Handle string and remaining types if str, ok := fieldValue.Interface().(string); ok { node.Attributes[args[1]] = str } else { From dc43f12cc7a7e56455e0f6b714b0684a7e87f1ac Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Sun, 14 Mar 2021 11:35:28 +0000 Subject: [PATCH 06/28] fix(response): respect `omitempty` and ensure `null` gets returned if the sql null type isn't valid --- response.go | 50 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/response.go b/response.go index d4439dc6..b20f4e18 100644 --- a/response.go +++ b/response.go @@ -418,27 +418,67 @@ func resolveNodeAttribute(node *Node, fieldValue reflect.Value, args []string) * // Deal with database/sql null types if nBool, ok := fieldValue.Interface().(sql.NullBool); ok { - node.Attributes[args[1]] = nBool.Bool + if !nBool.Valid && omitEmpty { + return node + } + + if nBool.Valid { + node.Attributes[args[1]] = nBool.Bool + } else { + node.Attributes[args[1]] = nil + } break } if nStr, ok := fieldValue.Interface().(sql.NullString); ok { - node.Attributes[args[1]] = nStr.String + if !nStr.Valid && omitEmpty { + return node + } + + if nStr.Valid { + node.Attributes[args[1]] = nStr.String + } else { + node.Attributes[args[1]] = nil + } break } if nF64, ok := fieldValue.Interface().(sql.NullFloat64); ok { - node.Attributes[args[1]] = nF64.Float64 + if !nF64.Valid && omitEmpty { + return node + } + + if nF64.Valid { + node.Attributes[args[1]] = nF64.Float64 + } else { + node.Attributes[args[1]] = nil + } break } if nI32, ok := fieldValue.Interface().(sql.NullInt32); ok { - node.Attributes[args[1]] = nI32.Int32 + if !nI32.Valid && omitEmpty { + return node + } + + if nI32.Valid { + node.Attributes[args[1]] = nI32.Int32 + } else { + node.Attributes[args[1]] = nil + } break } if nI64, ok := fieldValue.Interface().(sql.NullInt64); ok { - node.Attributes[args[1]] = nI64.Int64 + if !nI64.Valid && omitEmpty { + return node + } + + if nI64.Valid { + node.Attributes[args[1]] = nI64.Int64 + } else { + node.Attributes[args[1]] = nil + } break } From f56f4a35771a16f0cc57dcb3e9b39adc41fe431e Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Thu, 1 Apr 2021 09:49:09 +0100 Subject: [PATCH 07/28] chore(request): use the annotation separator constant --- request.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/request.go b/request.go index b2fa4770..5eafbbb6 100644 --- a/request.go +++ b/request.go @@ -160,7 +160,8 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) fieldValue := modelValue.Field(i) - args := strings.Split(tag, ",") + args := strings.Split(tag, annotationSeparator) + if len(args) < 1 { er = ErrBadJSONAPIStructTag break From 8018f1a8118e06b2dcced687d6351c271f54985d Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Thu, 1 Apr 2021 09:51:30 +0100 Subject: [PATCH 08/28] feat(request): return early and use switch/case in unmarshalNode() --- request.go | 44 +++++++++++++++++++------------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/request.go b/request.go index 5eafbbb6..9e076450 100644 --- a/request.go +++ b/request.go @@ -149,8 +149,6 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) modelValue := model.Elem() modelType := modelValue.Type() - var er error - for i := 0; i < modelValue.NumField(); i++ { fieldType := modelType.Field(i) tag := fieldType.Tag.Get("jsonapi") @@ -163,27 +161,25 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) args := strings.Split(tag, annotationSeparator) if len(args) < 1 { - er = ErrBadJSONAPIStructTag - break + return ErrBadJSONAPIStructTag } annotation := args[0] if (annotation == annotationClientID && len(args) != 1) || (annotation != annotationClientID && len(args) < 2) { - er = ErrBadJSONAPIStructTag - break + return ErrBadJSONAPIStructTag } - if annotation == annotationPrimary { + switch annotation { + case annotationPrimary: // Check the JSON API Type if data.Type != args[1] { - er = fmt.Errorf( + return fmt.Errorf( "Trying to Unmarshal an object of type %#v, but %#v does not match", data.Type, args[1], ) - break } if data.ID == "" { @@ -212,8 +208,7 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) floatValue, err := strconv.ParseFloat(data.ID, 64) if err != nil { // Could not convert the value in the "id" attr to a float - er = ErrBadJSONAPIID - break + return ErrBadJSONAPIID } // Convert the numeric float to one of the supported ID numeric types @@ -222,18 +217,19 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) if err != nil { // We had a JSON float (numeric), but our field was not one of the // allowed numeric types - er = ErrBadJSONAPIID - break + return ErrBadJSONAPIID } assign(fieldValue, idValue) - } else if annotation == annotationClientID { + + case annotationClientID: if data.ClientID == "" { continue } fieldValue.Set(reflect.ValueOf(data.ClientID)) - } else if annotation == annotationAttribute { + + case annotationAttribute: attributes := data.Attributes if attributes == nil || len(data.Attributes) == 0 { @@ -250,12 +246,12 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) structField := fieldType value, err := unmarshalAttribute(attribute, args, structField, fieldValue) if err != nil { - er = err - break + return err } assign(fieldValue, value) - } else if annotation == annotationRelation { + + case annotationRelation: isSlice := fieldValue.Type().Kind() == reflect.Slice if data.Relationships == nil || data.Relationships[args[1]] == nil { @@ -282,8 +278,7 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) m, included, ); err != nil { - er = err - break + return err } models = reflect.Append(models, m) @@ -317,20 +312,19 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) m, included, ); err != nil { - er = err - break + return err } fieldValue.Set(m) } - } else { - er = fmt.Errorf(unsupportedStructTagMsg, annotation) + default: + return fmt.Errorf(unsupportedStructTagMsg, annotation) } } - return er + return nil } func fullNode(n *Node, included *map[string]*Node) *Node { From 2ce5a7c7b3f1463283e8640b220e8a2ed709f594 Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Thu, 1 Apr 2021 12:02:13 +0100 Subject: [PATCH 09/28] refactor(request): move ID and relation resolution logic into their own functions - unmarshallID() - unmarshallRelation() --- request.go | 202 +++++++++++++++++++++++++++++------------------------ 1 file changed, 111 insertions(+), 91 deletions(-) diff --git a/request.go b/request.go index 9e076450..8a097469 100644 --- a/request.go +++ b/request.go @@ -182,46 +182,12 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) ) } - if data.ID == "" { - continue - } + data, err = unmarshallID(data, fieldValue, fieldType) - // ID will have to be transmitted as astring per the JSON API spec - v := reflect.ValueOf(data.ID) - - // Deal with PTRS - var kind reflect.Kind - if fieldValue.Kind() == reflect.Ptr { - kind = fieldType.Type.Elem().Kind() - } else { - kind = fieldType.Type.Kind() - } - - // Handle String case - if kind == reflect.String { - assign(fieldValue, v) - continue - } - - // Value was not a string... only other supported type was a numeric, - // which would have been sent as a float value. - floatValue, err := strconv.ParseFloat(data.ID, 64) - if err != nil { - // Could not convert the value in the "id" attr to a float - return ErrBadJSONAPIID - } - - // Convert the numeric float to one of the supported ID numeric types - // (int[8,16,32,64] or uint[8,16,32,64]) - idValue, err := handleNumeric(floatValue, fieldType.Type, fieldValue) if err != nil { - // We had a JSON float (numeric), but our field was not one of the - // allowed numeric types - return ErrBadJSONAPIID + return } - assign(fieldValue, idValue) - case annotationClientID: if data.ClientID == "" { continue @@ -252,79 +218,133 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) assign(fieldValue, value) case annotationRelation: - isSlice := fieldValue.Type().Kind() == reflect.Slice + data, err = unmarshallRelation(data, fieldValue, included, args) - if data.Relationships == nil || data.Relationships[args[1]] == nil { - continue + if err != nil { + return } - if isSlice { - // to-many relationship - relationship := new(RelationshipManyNode) + default: + return fmt.Errorf(unsupportedStructTagMsg, annotation) + } + } - buf := bytes.NewBuffer(nil) + return nil +} - json.NewEncoder(buf).Encode(data.Relationships[args[1]]) - json.NewDecoder(buf).Decode(relationship) +func unmarshallID(node *Node, fieldValue reflect.Value, fieldType reflect.StructField) (*Node, error) { + if node.ID == "" { + return node, nil + } - data := relationship.Data - models := reflect.New(fieldValue.Type()).Elem() + // ID will have to be transmitted as a string per the JSON API spec + v := reflect.ValueOf(node.ID) - for _, n := range data { - m := reflect.New(fieldValue.Type().Elem().Elem()) + // Deal with PTRS + var kind reflect.Kind + if fieldValue.Kind() == reflect.Ptr { + kind = fieldType.Type.Elem().Kind() + } else { + kind = fieldType.Type.Kind() + } - if err := unmarshalNode( - fullNode(n, included), - m, - included, - ); err != nil { - return err - } + // Handle String case + if kind == reflect.String { + assign(fieldValue, v) - models = reflect.Append(models, m) - } + return node, nil + } - fieldValue.Set(models) - } else { - // to-one relationships - relationship := new(RelationshipOneNode) + // Value was not a string... only other supported type was a numeric, + // which would have been sent as a float value. + floatValue, err := strconv.ParseFloat(node.ID, 64) + if err != nil { + // Could not convert the value in the "id" attr to a float + return nil, ErrBadJSONAPIID + } - buf := bytes.NewBuffer(nil) + // Convert the numeric float to one of the supported ID numeric types + // (int[8,16,32,64] or uint[8,16,32,64]) + idValue, err := handleNumeric(floatValue, fieldType.Type, fieldValue) + if err != nil { + // We had a JSON float (numeric), but our field was not one of the + // allowed numeric types + return nil, ErrBadJSONAPIID + } - json.NewEncoder(buf).Encode( - data.Relationships[args[1]], - ) - json.NewDecoder(buf).Decode(relationship) - - /* - http://jsonapi.org/format/#document-resource-object-relationships - http://jsonapi.org/format/#document-resource-object-linkage - relationship can have a data node set to null (e.g. to disassociate the relationship) - so unmarshal and set fieldValue only if data obj is not null - */ - if relationship.Data == nil { - continue - } - - m := reflect.New(fieldValue.Type().Elem()) - if err := unmarshalNode( - fullNode(relationship.Data, included), - m, - included, - ); err != nil { - return err - } - - fieldValue.Set(m) + assign(fieldValue, idValue) + + return node, nil +} + +func unmarshallRelation(node *Node, fieldValue reflect.Value, included *map[string]*Node, args []string) (*Node, error) { + isSlice := fieldValue.Type().Kind() == reflect.Slice + + if node.Relationships == nil || node.Relationships[args[1]] == nil { + return node, nil + } + + if isSlice { + // to-many relationship + relationship := new(RelationshipManyNode) + + buf := bytes.NewBuffer(nil) + + json.NewEncoder(buf).Encode(node.Relationships[args[1]]) + json.NewDecoder(buf).Decode(relationship) + data := relationship.Data + models := reflect.New(fieldValue.Type()).Elem() + + for _, n := range data { + m := reflect.New(fieldValue.Type().Elem().Elem()) + + if err := unmarshalNode( + fullNode(n, included), + m, + included, + ); err != nil { + return nil, err } - default: - return fmt.Errorf(unsupportedStructTagMsg, annotation) + models = reflect.Append(models, m) + } + + fieldValue.Set(models) + } else { + // to-one relationships + relationship := new(RelationshipOneNode) + + buf := bytes.NewBuffer(nil) + + json.NewEncoder(buf).Encode( + node.Relationships[args[1]], + ) + json.NewDecoder(buf).Decode(relationship) + + /* + http://jsonapi.org/format/#document-resource-object-relationships + http://jsonapi.org/format/#document-resource-object-linkage + relationship can have a data node set to null (e.g. to disassociate the relationship) + so unmarshal and set fieldValue only if data obj is not null + */ + if relationship.Data == nil { + return node, nil + } + + m := reflect.New(fieldValue.Type().Elem()) + if err := unmarshalNode( + fullNode(relationship.Data, included), + m, + included, + ); err != nil { + return nil, err } + + fieldValue.Set(m) } - return nil + return node, nil } func fullNode(n *Node, included *map[string]*Node) *Node { From 35580864a82ec8d947d5e2240c4478f1571ed8f7 Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Thu, 1 Apr 2021 12:06:52 +0100 Subject: [PATCH 10/28] chore(request): use the annotationJSONAPI constant --- request.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/request.go b/request.go index 8a097469..d67b7052 100644 --- a/request.go +++ b/request.go @@ -151,7 +151,7 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) for i := 0; i < modelValue.NumField(); i++ { fieldType := modelType.Field(i) - tag := fieldType.Tag.Get("jsonapi") + tag := fieldType.Tag.Get(annotationJSONAPI) if tag == "" { continue } From c7d7a2ff6c437eb52d02c92f024d69b01d806732 Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Thu, 1 Apr 2021 23:14:50 +0100 Subject: [PATCH 11/28] refactor(request): simplify ISO8601 logic in handleTime() --- request.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/request.go b/request.go index d67b7052..3eecd3f2 100644 --- a/request.go +++ b/request.go @@ -472,14 +472,11 @@ func handleTime(attribute interface{}, args []string, fieldValue reflect.Value) } if isIso8601 { - var tm string - if v.Kind() == reflect.String { - tm = v.Interface().(string) - } else { + if v.Kind() != reflect.String { return reflect.ValueOf(time.Now()), ErrInvalidISO8601 } - t, err := time.Parse(iso8601TimeFormat, tm) + t, err := time.Parse(iso8601TimeFormat, v.Interface().(string)) if err != nil { return reflect.ValueOf(time.Now()), ErrInvalidISO8601 } From 8f0d58aec8f3d241d3786f976cedf0f7c6ed3072 Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Thu, 1 Apr 2021 23:26:38 +0100 Subject: [PATCH 12/28] refactor(request): simplify remaining logic in handleTime() --- request.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/request.go b/request.go index 3eecd3f2..85d0cf55 100644 --- a/request.go +++ b/request.go @@ -488,18 +488,16 @@ func handleTime(attribute interface{}, args []string, fieldValue reflect.Value) return reflect.ValueOf(t), nil } - var at int64 + var t time.Time if v.Kind() == reflect.Float64 { - at = int64(v.Interface().(float64)) + t = time.Unix(int64(v.Float()), 0) } else if v.Kind() == reflect.Int { - at = v.Int() + t = time.Unix(v.Int(), 0) } else { return reflect.ValueOf(time.Now()), ErrInvalidTime } - t := time.Unix(at, 0) - return reflect.ValueOf(t), nil } From aee8575e02ab40f6848eda300fd0aec4b545073b Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Fri, 2 Apr 2021 01:17:34 +0100 Subject: [PATCH 13/28] chore(request): rename unmarshallID() argument to structField --- request.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/request.go b/request.go index 85d0cf55..8508cd38 100644 --- a/request.go +++ b/request.go @@ -232,7 +232,7 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) return nil } -func unmarshallID(node *Node, fieldValue reflect.Value, fieldType reflect.StructField) (*Node, error) { +func unmarshallID(node *Node, fieldValue reflect.Value, structField reflect.StructField) (*Node, error) { if node.ID == "" { return node, nil } @@ -243,9 +243,9 @@ func unmarshallID(node *Node, fieldValue reflect.Value, fieldType reflect.Struct // Deal with PTRS var kind reflect.Kind if fieldValue.Kind() == reflect.Ptr { - kind = fieldType.Type.Elem().Kind() + kind = structField.Type.Elem().Kind() } else { - kind = fieldType.Type.Kind() + kind = structField.Type.Kind() } // Handle String case @@ -265,7 +265,7 @@ func unmarshallID(node *Node, fieldValue reflect.Value, fieldType reflect.Struct // Convert the numeric float to one of the supported ID numeric types // (int[8,16,32,64] or uint[8,16,32,64]) - idValue, err := handleNumeric(floatValue, fieldType.Type, fieldValue) + idValue, err := handleNumeric(floatValue, structField.Type, fieldValue) if err != nil { // We had a JSON float (numeric), but our field was not one of the // allowed numeric types From cd458d53ae9f2df6de0253727a028c518ce3f96f Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Fri, 2 Apr 2021 03:16:26 +0100 Subject: [PATCH 14/28] feat(request): add support for sql.Null(Int32, Int64, Float64) in handleNumeric() function --- request.go | 20 +++++++++++++++++++- response.go | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/request.go b/request.go index 8508cd38..6a908aff 100644 --- a/request.go +++ b/request.go @@ -2,6 +2,7 @@ package jsonapi import ( "bytes" + "database/sql" "encoding/json" "errors" "fmt" @@ -264,7 +265,7 @@ func unmarshallID(node *Node, fieldValue reflect.Value, structField reflect.Stru } // Convert the numeric float to one of the supported ID numeric types - // (int[8,16,32,64] or uint[8,16,32,64]) + // (int[8,16,32,64], uint[8,16,32,64] or sql.Null[Int32, Int64, Float64]) idValue, err := handleNumeric(floatValue, structField.Type, fieldValue) if err != nil { // We had a JSON float (numeric), but our field was not one of the @@ -554,6 +555,23 @@ func handleNumeric( case reflect.Float64: n := floatValue numericValue = reflect.ValueOf(&n) + case reflect.Struct: + if _, ok := fieldValue.Interface().(sql.NullInt32); ok { + numericValue = reflect.ValueOf(sql.NullInt32{Int32: int32(floatValue), Valid: true}) + break + } + + if _, ok := fieldValue.Interface().(sql.NullInt64); ok { + numericValue = reflect.ValueOf(sql.NullInt64{Int64: int64(floatValue), Valid: true}) + break + } + + if _, ok := fieldValue.Interface().(sql.NullFloat64); ok { + numericValue = reflect.ValueOf(sql.NullFloat64{Float64: floatValue, Valid: true}) + break + } + + fallthrough default: return reflect.Value{}, ErrUnknownFieldNumberType } diff --git a/response.go b/response.go index b20f4e18..e32d98f6 100644 --- a/response.go +++ b/response.go @@ -19,7 +19,7 @@ var ( // ErrBadJSONAPIID is returned when the Struct JSON API annotated "id" field // was not a valid numeric type. ErrBadJSONAPIID = errors.New( - "id should be either string, int(8,16,32,64) or uint(8,16,32,64)") + "id should be either string, int(8,16,32,64), uint(8,16,32,64) or sql.Null(Int32, Int64, Float64)") // ErrExpectedSlice is returned when a variable or argument was expected to // be a slice of *Structs; MarshalMany will return this error when its // interface{} argument is invalid. From 88da138ff733fc58bc89c6bacff75892dd11a361 Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Fri, 2 Apr 2021 03:18:03 +0100 Subject: [PATCH 15/28] feat(request): add support for sql.NullTime in the handleTime() function --- request.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/request.go b/request.go index 6a908aff..dc296a99 100644 --- a/request.go +++ b/request.go @@ -482,6 +482,10 @@ func handleTime(attribute interface{}, args []string, fieldValue reflect.Value) return reflect.ValueOf(time.Now()), ErrInvalidISO8601 } + if _, ok := fieldValue.Interface().(sql.NullTime); ok { + return reflect.ValueOf(sql.NullTime{Time: t, Valid: true}), nil + } + if fieldValue.Kind() == reflect.Ptr { return reflect.ValueOf(&t), nil } @@ -499,6 +503,10 @@ func handleTime(attribute interface{}, args []string, fieldValue reflect.Value) return reflect.ValueOf(time.Now()), ErrInvalidTime } + if _, ok := fieldValue.Interface().(sql.NullTime); ok { + return reflect.ValueOf(sql.NullTime{Time: t, Valid: true}), nil + } + return reflect.ValueOf(t), nil } From 44993c33bdde3ddacaff3e6fed178545832bd446 Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Fri, 2 Apr 2021 03:21:22 +0100 Subject: [PATCH 16/28] feat(request): handle sql.Null* type fields in the unmarshalAttribute() function - implement isSQLNullType() and handleSQLNullType() functions --- request.go | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/request.go b/request.go index dc296a99..24d1d43a 100644 --- a/request.go +++ b/request.go @@ -409,6 +409,12 @@ func unmarshalAttribute( return } + // Handle field of sql.Null* type + if isSQLNullType(fieldType) { + value, err = handleSQLNullType(attribute, args, fieldType, fieldValue) + return + } + // Handle field of type time.Time if fieldValue.Type() == reflect.TypeOf(time.Time{}) || fieldValue.Type() == reflect.TypeOf(new(time.Time)) { @@ -624,6 +630,45 @@ func handlePointer( return concreteVal, nil } +func isSQLNullType(fieldType reflect.Type) bool { + switch fieldType { + case reflect.TypeOf(sql.NullString{}): + fallthrough + case reflect.TypeOf(sql.NullBool{}): + fallthrough + case reflect.TypeOf(sql.NullInt32{}): + fallthrough + case reflect.TypeOf(sql.NullInt64{}): + fallthrough + case reflect.TypeOf(sql.NullFloat64{}): + fallthrough + case reflect.TypeOf(sql.NullTime{}): + return true + } + + return false +} + +func handleSQLNullType(attribute interface{}, args []string, fieldType reflect.Type, + fieldValue reflect.Value) (reflect.Value, error) { + switch fieldType { + case reflect.TypeOf(sql.NullString{}): + return reflect.ValueOf(sql.NullString{String: attribute.(string), Valid: true}), nil + case reflect.TypeOf(sql.NullBool{}): + return reflect.ValueOf(sql.NullBool{Bool: attribute.(bool), Valid: true}), nil + case reflect.TypeOf(sql.NullInt32{}): + fallthrough + case reflect.TypeOf(sql.NullInt64{}): + fallthrough + case reflect.TypeOf(sql.NullFloat64{}): + return handleNumeric(attribute, fieldType, fieldValue) + case reflect.TypeOf(sql.NullTime{}): + return handleTime(attribute, args, fieldValue) + } + + return reflect.Value{}, fmt.Errorf("expected sql.Null* type, got: %v", fieldType) +} + func handleStruct( attribute interface{}, fieldValue reflect.Value) (reflect.Value, error) { From 45b18390b36344fdc18b77ad7938d2f9a17fe45b Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Fri, 2 Apr 2021 03:23:50 +0100 Subject: [PATCH 17/28] feat(request): support sql.NullString ID field --- request.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/request.go b/request.go index 24d1d43a..a76148c2 100644 --- a/request.go +++ b/request.go @@ -256,6 +256,15 @@ func unmarshallID(node *Node, fieldValue reflect.Value, structField reflect.Stru return node, nil } + // Handle sql.NullString case + if structField.Type == reflect.TypeOf(sql.NullString{}) { + if str, ok := v.Interface().(string); ok { + assign(fieldValue, reflect.ValueOf(sql.NullString{String: str, Valid: true})) + + return node, nil + } + } + // Value was not a string... only other supported type was a numeric, // which would have been sent as a float value. floatValue, err := strconv.ParseFloat(node.ID, 64) From cc191bca94a65c1e6172751e0f9f9eaa084c3096 Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Fri, 2 Apr 2021 03:26:46 +0100 Subject: [PATCH 18/28] tests(request): add coverage for the new sql.Null* type support --- models_test.go | 31 ++++++++++- request_test.go | 144 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 3 deletions(-) diff --git a/models_test.go b/models_test.go index 2d4aae4d..39e640a5 100644 --- a/models_test.go +++ b/models_test.go @@ -1,6 +1,7 @@ package jsonapi import ( + "database/sql" "fmt" "time" ) @@ -26,9 +27,33 @@ type WithPointer struct { } type Timestamp struct { - ID int `jsonapi:"primary,timestamps"` - Time time.Time `jsonapi:"attr,timestamp,iso8601"` - Next *time.Time `jsonapi:"attr,next,iso8601"` + ID int `jsonapi:"primary,timestamps"` + Time time.Time `jsonapi:"attr,timestamp,iso8601"` + Next *time.Time `jsonapi:"attr,next,iso8601"` + Null sql.NullTime `jsonapi:"attr,null,iso8601"` +} + +type NullStringID struct { + ID sql.NullString `jsonapi:"primary,null-string-id"` + Periodic sql.NullBool `jsonapi:"attr,periodic,omitempty"` + Name sql.NullString `jsonapi:"attr,name,omitempty"` + Value sql.NullFloat64 `jsonapi:"attr,value,omitempty"` + Decimal sql.NullInt32 `jsonapi:"attr,decimal,omitempty"` + Fractional sql.NullInt64 `jsonapi:"attr,fractional,omitempty"` + ComputedAt sql.NullTime `jsonapi:"attr,computed_at,omitempty"` + ComputedAtISO sql.NullTime `jsonapi:"attr,computed_at_iso,iso8601,omitempty"` +} + +type NullInt32ID struct { + ID sql.NullInt32 `jsonapi:"primary,null-int32-id"` +} + +type NullInt64ID struct { + ID sql.NullInt64 `jsonapi:"primary,null-int64-id"` +} + +type NullFloat64ID struct { + ID sql.NullFloat64 `jsonapi:"primary,null-float64-id"` } type Car struct { diff --git a/request_test.go b/request_test.go index daa21597..afa69e02 100644 --- a/request_test.go +++ b/request_test.go @@ -212,6 +212,124 @@ func TestUnmarshalToStructWithPointerAttr_BadType_IntSlice(t *testing.T) { } } +func TestUnmarshalToStructNullStringID(t *testing.T) { + data := map[string]interface{}{ + "data": map[string]interface{}{ + "type": "null-string-id", + "id": "314", + "attributes": map[string]interface{}{ + "periodic": false, + "name": "Pi", + "value": 3.1415926535897932, + "decimal": 3, + "fractional": 1415926535897932, + "computed_at": 1615734000, + "computed_at_iso": "2021-03-14T15:00:00Z", + }, + }, + } + payload, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + + pi := new(NullStringID) + if err = UnmarshalPayload(bytes.NewReader(payload), pi); err != nil { + t.Fatal(err) + } + + if pi.ID.String != "314" { + t.Fatalf("Error unmarshalling to sql.NullString") + } + if pi.Name.String != "Pi" { + t.Fatalf("Error unmarshalling to sql.NullString") + } + if pi.Periodic.Bool { + t.Fatalf("Error unmarshalling to sql.NullBool") + } + if pi.Value.Float64 != 3.1415926535897932 { + t.Fatalf("Error unmarshalling to sql.NullFloat64") + } + if pi.Decimal.Int32 != 3 { + t.Fatalf("Error unmarshalling to sql.NullInt32") + } + if pi.Fractional.Int64 != 1415926535897932 { + t.Fatalf("Error unmarshalling to sql.NullInt64") + } + if !pi.ComputedAt.Time.Equal(time.Unix(1615734000, 0)) { + t.Fatalf("Error unmarshalling to sql.NullTime") + } + if !pi.ComputedAtISO.Time.Equal(time.Unix(1615734000, 0)) { + t.Fatalf("Error unmarshalling to sql.NullTime") + } +} + +func TestUnmarshalToStructNullInt32ID(t *testing.T) { + data := map[string]interface{}{ + "data": map[string]interface{}{ + "type": "null-int32-id", + "id": "123", + }, + } + payload, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + + i32 := new(NullInt32ID) + if err = UnmarshalPayload(bytes.NewReader(payload), i32); err != nil { + t.Fatal(err) + } + + if i32.ID.Int32 != 123 { + t.Fatalf("Error unmarshalling to sql.NullInt32") + } +} + +func TestUnmarshalToStructNullInt64ID(t *testing.T) { + data := map[string]interface{}{ + "data": map[string]interface{}{ + "type": "null-int64-id", + "id": "456", + }, + } + payload, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + + i64 := new(NullInt64ID) + if err = UnmarshalPayload(bytes.NewReader(payload), i64); err != nil { + t.Fatal(err) + } + + if i64.ID.Int64 != 456 { + t.Fatalf("Error unmarshalling to sql.NullInt64") + } +} + +func TestUnmarshalToStructNullFloat64ID(t *testing.T) { + data := map[string]interface{}{ + "data": map[string]interface{}{ + "type": "null-float64-id", + "id": "789", + }, + } + payload, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + + f64 := new(NullFloat64ID) + if err = UnmarshalPayload(bytes.NewReader(payload), f64); err != nil { + t.Fatal(err) + } + + if f64.ID.Float64 != 789 { + t.Fatalf("Error unmarshalling to sql.NullFloat64") + } +} + func TestStringPointerField(t *testing.T) { // Build Book payload description := "Hello World!" @@ -393,6 +511,32 @@ func TestUnmarshalParsesISO8601TimePointer(t *testing.T) { } } +func TestUnmarshalParsesISO8601NullTime(t *testing.T) { + payload := &OnePayload{ + Data: &Node{ + Type: "timestamps", + Attributes: map[string]interface{}{ + "null": "2016-08-17T08:27:12Z", + }, + }, + } + + in := bytes.NewBuffer(nil) + json.NewEncoder(in).Encode(payload) + + out := new(Timestamp) + + if err := UnmarshalPayload(in, out); err != nil { + t.Fatal(err) + } + + expected := time.Date(2016, 8, 17, 8, 27, 12, 0, time.UTC) + + if !out.Null.Time.Equal(expected) { + t.Fatal("Parsing the ISO8601 timestamp failed") + } +} + func TestUnmarshalInvalidISO8601(t *testing.T) { payload := &OnePayload{ Data: &Node{ From e7d4300d3a299349aa04744975234cc1a3053c31 Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Fri, 2 Apr 2021 04:32:25 +0100 Subject: [PATCH 19/28] fix(response): sql.NullTime field omission logic --- response.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/response.go b/response.go index e32d98f6..6f6aec14 100644 --- a/response.go +++ b/response.go @@ -397,7 +397,7 @@ func resolveNodeAttribute(node *Node, fieldValue reflect.Value, args []string) * node.Attributes[args[1]] = nil } else { - if nt.Time.IsZero() && omitEmpty { + if nt.Time.IsZero() { return node } From e038f2bcd8906d5b8ba637ced55cfedfa408745b Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Fri, 2 Apr 2021 04:51:25 +0100 Subject: [PATCH 20/28] chore(request): cleanup switch/cases --- request.go | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/request.go b/request.go index a76148c2..9e15c8ae 100644 --- a/request.go +++ b/request.go @@ -641,17 +641,8 @@ func handlePointer( func isSQLNullType(fieldType reflect.Type) bool { switch fieldType { - case reflect.TypeOf(sql.NullString{}): - fallthrough - case reflect.TypeOf(sql.NullBool{}): - fallthrough - case reflect.TypeOf(sql.NullInt32{}): - fallthrough - case reflect.TypeOf(sql.NullInt64{}): - fallthrough - case reflect.TypeOf(sql.NullFloat64{}): - fallthrough - case reflect.TypeOf(sql.NullTime{}): + case reflect.TypeOf(sql.NullString{}), reflect.TypeOf(sql.NullBool{}), reflect.TypeOf(sql.NullInt32{}), + reflect.TypeOf(sql.NullInt64{}), reflect.TypeOf(sql.NullFloat64{}), reflect.TypeOf(sql.NullTime{}): return true } @@ -665,11 +656,7 @@ func handleSQLNullType(attribute interface{}, args []string, fieldType reflect.T return reflect.ValueOf(sql.NullString{String: attribute.(string), Valid: true}), nil case reflect.TypeOf(sql.NullBool{}): return reflect.ValueOf(sql.NullBool{Bool: attribute.(bool), Valid: true}), nil - case reflect.TypeOf(sql.NullInt32{}): - fallthrough - case reflect.TypeOf(sql.NullInt64{}): - fallthrough - case reflect.TypeOf(sql.NullFloat64{}): + case reflect.TypeOf(sql.NullInt32{}), reflect.TypeOf(sql.NullInt64{}), reflect.TypeOf(sql.NullFloat64{}): return handleNumeric(attribute, fieldType, fieldValue) case reflect.TypeOf(sql.NullTime{}): return handleTime(attribute, args, fieldValue) From 2dccc85d9f8da89cba7e1d324dbf75ca648e4f68 Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Fri, 2 Apr 2021 04:53:29 +0100 Subject: [PATCH 21/28] feat(response): support sql.NullFloat64 ID field type --- response.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/response.go b/response.go index 6f6aec14..ac0a3f9b 100644 --- a/response.go +++ b/response.go @@ -309,18 +309,23 @@ func resolveNodeID(node *Node, fieldValue reflect.Value, fieldType reflect.Struc case reflect.Uint64: node.ID = strconv.FormatUint(v.Interface().(uint64), 10) case reflect.Struct: - if nStr, ok := v.Interface().(sql.NullString); ok { - node.ID = nStr.String + if str, ok := v.Interface().(sql.NullString); ok { + node.ID = str.String break } - if nI32, ok := v.Interface().(sql.NullInt32); ok { - node.ID = strconv.FormatInt(int64(nI32.Int32), 10) + if i32, ok := v.Interface().(sql.NullInt32); ok { + node.ID = strconv.FormatInt(int64(i32.Int32), 10) break } - if nI64, ok := v.Interface().(sql.NullInt64); ok { - node.ID = strconv.FormatInt(nI64.Int64, 10) + if i64, ok := v.Interface().(sql.NullInt64); ok { + node.ID = strconv.FormatInt(i64.Int64, 10) + break + } + + if f64, ok := v.Interface().(sql.NullFloat64); ok { + node.ID = strconv.FormatFloat(f64.Float64, 'f', -1, 64) break } From 300c9b86247703a4a6078b996d99fd8d888472a8 Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Fri, 2 Apr 2021 04:54:53 +0100 Subject: [PATCH 22/28] chore(request): rename resolveNodeID() argument to structField --- response.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/response.go b/response.go index ac0a3f9b..d3abecbd 100644 --- a/response.go +++ b/response.go @@ -272,16 +272,16 @@ func visitModelNode(model interface{}, included *map[string]*Node, return node, nil } -func resolveNodeID(node *Node, fieldValue reflect.Value, fieldType reflect.StructField) (*Node, error) { +func resolveNodeID(node *Node, fieldValue reflect.Value, structField reflect.StructField) (*Node, error) { v := fieldValue // Deal with PTRS var kind reflect.Kind if fieldValue.Kind() == reflect.Ptr { - kind = fieldType.Type.Elem().Kind() + kind = structField.Type.Elem().Kind() v = reflect.Indirect(fieldValue) } else { - kind = fieldType.Type.Kind() + kind = structField.Type.Kind() } // Handle allowed types From f3689307a702d59231b700a18648138edbecfa8c Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Fri, 2 Apr 2021 15:28:10 +0100 Subject: [PATCH 23/28] refactor(response): simplify sql.Null* type handling logic in resolveNodeAttribute() --- response.go | 62 ++++++++++------------------------------------------- 1 file changed, 11 insertions(+), 51 deletions(-) diff --git a/response.go b/response.go index d3abecbd..7f0562b0 100644 --- a/response.go +++ b/response.go @@ -418,72 +418,32 @@ func resolveNodeAttribute(node *Node, fieldValue reflect.Value, args []string) * // See if we need to omit this field if omitEmpty && reflect.DeepEqual(fieldValue.Interface(), emptyValue.Interface()) { - return node + break } - // Deal with database/sql null types + // Handle remaining sql.Null* types if nBool, ok := fieldValue.Interface().(sql.NullBool); ok { - if !nBool.Valid && omitEmpty { - return node - } - - if nBool.Valid { - node.Attributes[args[1]] = nBool.Bool - } else { - node.Attributes[args[1]] = nil - } + node.Attributes[args[1]] = nBool.Bool break } - if nStr, ok := fieldValue.Interface().(sql.NullString); ok { - if !nStr.Valid && omitEmpty { - return node - } - - if nStr.Valid { - node.Attributes[args[1]] = nStr.String - } else { - node.Attributes[args[1]] = nil - } + if str, ok := fieldValue.Interface().(sql.NullString); ok { + node.Attributes[args[1]] = str.String break } - if nF64, ok := fieldValue.Interface().(sql.NullFloat64); ok { - if !nF64.Valid && omitEmpty { - return node - } - - if nF64.Valid { - node.Attributes[args[1]] = nF64.Float64 - } else { - node.Attributes[args[1]] = nil - } + if f64, ok := fieldValue.Interface().(sql.NullFloat64); ok { + node.Attributes[args[1]] = f64.Float64 break } - if nI32, ok := fieldValue.Interface().(sql.NullInt32); ok { - if !nI32.Valid && omitEmpty { - return node - } - - if nI32.Valid { - node.Attributes[args[1]] = nI32.Int32 - } else { - node.Attributes[args[1]] = nil - } + if i32, ok := fieldValue.Interface().(sql.NullInt32); ok { + node.Attributes[args[1]] = i32.Int32 break } - if nI64, ok := fieldValue.Interface().(sql.NullInt64); ok { - if !nI64.Valid && omitEmpty { - return node - } - - if nI64.Valid { - node.Attributes[args[1]] = nI64.Int64 - } else { - node.Attributes[args[1]] = nil - } + if i64, ok := fieldValue.Interface().(sql.NullInt64); ok { + node.Attributes[args[1]] = i64.Int64 break } From 6e06742c1c31664a853001a2ac134e3993a52862 Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Fri, 2 Apr 2021 15:29:05 +0100 Subject: [PATCH 24/28] fix(response): typo --- response.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/response.go b/response.go index 7f0562b0..7598c837 100644 --- a/response.go +++ b/response.go @@ -174,7 +174,7 @@ func marshalMany(models []interface{}) (*ManyPayload, error) { // related records. This method will serialize a single struct // pointer into an embedded json response. In other words, there // will be no, "included", array in the json all relationships will -// be serailized inline in the data. +// be serialized inline in the data. // // However, in tests, you may want to construct payloads to post // to create methods that are embedded to most closely resemble From 516918a05eeb8a69cd5914e3c94646e72f14d1a6 Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Fri, 2 Apr 2021 16:27:14 +0100 Subject: [PATCH 25/28] tests(response): add coverage for the new sql.Null* type support --- response_test.go | 313 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 311 insertions(+), 2 deletions(-) diff --git a/response_test.go b/response_test.go index 5b425955..160e0061 100644 --- a/response_test.go +++ b/response_test.go @@ -2,6 +2,7 @@ package jsonapi import ( "bytes" + "database/sql" "encoding/json" "reflect" "sort" @@ -116,7 +117,7 @@ func TestWithoutOmitsEmptyAnnotationOnRelation(t *testing.T) { } relationships := jsonData["data"].(map[string]interface{})["relationships"].(map[string]interface{}) - // Verifiy the "posts" relation was an empty array + // Verify the "posts" relation was an empty array posts, ok := relationships["posts"] if !ok { t.Fatal("Was expecting the data.relationships.posts key/value to have been present") @@ -137,7 +138,7 @@ func TestWithoutOmitsEmptyAnnotationOnRelation(t *testing.T) { t.Fatal("Was expecting the data.relationships.posts.data value to have been an empty array []") } - // Verifiy the "current_post" was a null + // Verify the "current_post" was a null currentPost, postExists := relationships["current_post"] if !postExists { t.Fatal("Was expecting the data.relationships.current_post key/value to have NOT been omitted") @@ -525,6 +526,314 @@ func TestMarshalISO8601TimePointer(t *testing.T) { } } +func TestMarshalISO8601NullTime(t *testing.T) { + testModel := &Timestamp{ + ID: 5, + Null: sql.NullTime{ + Time: time.Date(2016, 8, 17, 8, 27, 12, 23849, time.UTC), + Valid: true, + }, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, testModel); err != nil { + t.Fatal(err) + } + + resp := new(OnePayload) + if err := json.NewDecoder(out).Decode(resp); err != nil { + t.Fatal(err) + } + + data := resp.Data + + if data.Attributes == nil { + t.Fatalf("Expected attributes") + } + + if data.Attributes["null"] != "2016-08-17T08:27:12Z" { + t.Fatal("Null was not serialised into ISO8601 correctly") + } +} + +func TestMarshalISO8601NullTime_Zero(t *testing.T) { + testModel := &Timestamp{ + ID: 5, + Null: sql.NullTime{Valid: true}, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, testModel); err != nil { + t.Fatal(err) + } + + resp := new(OnePayload) + if err := json.NewDecoder(out).Decode(resp); err != nil { + t.Fatal(err) + } + + data := resp.Data + + if data.Attributes == nil { + t.Fatal("Expected attributes") + } + + if data.Attributes["null"] != nil { + t.Fatalf("Null should not have been serialised") + } +} + +func TestMarshalStructNullStringID_Zero_Invalid(t *testing.T) { + pi := new(NullStringID) + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, pi); err != nil { + t.Fatal(err) + } + + var jsonData map[string]interface{} + + if err := json.Unmarshal(out.Bytes(), &jsonData); err != nil { + t.Fatal(err) + } + data := jsonData["data"].(map[string]interface{}) + + if data["type"] != "null-string-id" { + t.Fatalf("Error marshalling type") + } + + if _, ok := data["attributes"]; ok { + t.Fatal("Was expecting data.attributes to be omitted") + } +} + +func TestMarshalStructNullStringID_Zero_Valid(t *testing.T) { + pi := &NullStringID{ + ID: sql.NullString{Valid: true}, + Periodic: sql.NullBool{Valid: true}, + Name: sql.NullString{Valid: true}, + Value: sql.NullFloat64{Valid: true}, + Decimal: sql.NullInt32{Valid: true}, + Fractional: sql.NullInt64{Valid: true}, + ComputedAt: sql.NullTime{Valid: true}, + ComputedAtISO: sql.NullTime{Valid: true}, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, pi); err != nil { + t.Fatal(err) + } + + var jsonData map[string]interface{} + + if err := json.Unmarshal(out.Bytes(), &jsonData); err != nil { + t.Fatal(err) + } + data := jsonData["data"].(map[string]interface{}) + + if _, ok := data["id"]; ok { + t.Fatal("Was expecting data.id to be omitted") + } + + if data["type"] != "null-string-id" { + t.Fatalf("Error marshalling type") + } + + attributes := data["attributes"].(map[string]interface{}) + + if attributes["periodic"] != false { + t.Fatalf("Error marshalling to sql.NullBool: %v", attributes["periodic"]) + } + + if attributes["name"] != "" { + t.Fatal("Error marshalling to sql.NullString") + } + + if attributes["value"] != 0.0 { + t.Fatal("Error marshalling to sql.NullFloat64") + } + + if attributes["decimal"] != 0.0 { + t.Fatalf("Error marshalling to sql.NullInt32") + } + + if attributes["fractional"] != 0.0 { + t.Fatalf("Error marshalling to sql.NullInt64") + } + + if _, ok := attributes["computed_at"]; ok { + t.Fatal("Was expecting data.attributes.computed_at to be omitted") + } + + if _, ok := attributes["computed_at_iso"]; ok { + t.Fatal("Was expecting data.attributes.computed_at_iso to be omitted") + } +} + +func TestMarshalStructNullStringID(t *testing.T) { + pi := &NullStringID{ + ID: sql.NullString{ + String: "314", + Valid: true, + }, + Periodic: sql.NullBool{ + Bool: false, + Valid: true, + }, + Name: sql.NullString{ + String: "Pi", + Valid: true, + }, + Value: sql.NullFloat64{ + Float64: 3.1415926535897932, + Valid: true, + }, + Decimal: sql.NullInt32{ + Int32: 3, + Valid: true, + }, + Fractional: sql.NullInt64{ + Int64: 1415926535897932, + Valid: true, + }, + ComputedAt: sql.NullTime{ + Time: time.Date(2021, 3, 14, 15, 0, 0, 0, time.UTC), + Valid: true, + }, + ComputedAtISO: sql.NullTime{ + Time: time.Date(2021, 3, 14, 15, 0, 0, 0, time.UTC), + Valid: true, + }, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, pi); err != nil { + t.Fatal(err) + } + + resp := new(OnePayload) + if err := json.Unmarshal(out.Bytes(), resp); err != nil { + t.Fatal(err) + } + + data := resp.Data + + if data.ID != "314" { + t.Fatal("Error marshalling id") + } + + if data.Type != "null-string-id" { + t.Fatal("Error marshalling type") + } + + if data.Attributes["periodic"] != false { + t.Fatal("Error marshalling to sql.NullBool") + } + + if data.Attributes["name"] != "Pi" { + t.Fatal("Error marshalling to sql.NullString") + } + + if data.Attributes["value"] != 3.1415926535897932 { + t.Fatal("Error marshalling to sql.NullFloat64") + } + + if data.Attributes["decimal"] != 3.0 { + t.Fatalf("Error marshalling to sql.NullInt32") + } + + if data.Attributes["computed_at_iso"] != "2021-03-14T15:00:00Z" { + t.Fatalf("Error marshalling to sql.NullTime") + } +} + +func TestMarshalStructNullInt32ID(t *testing.T) { + i32 := &NullInt32ID{ + ID: sql.NullInt32{ + Int32: 123, + Valid: true, + }, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, i32); err != nil { + t.Fatal(err) + } + + resp := new(OnePayload) + if err := json.Unmarshal(out.Bytes(), resp); err != nil { + t.Fatal(err) + } + + data := resp.Data + + if data.ID != "123" { + t.Fatalf("Error marshalling id") + } + + if data.Type != "null-int32-id" { + t.Fatal("Error marshalling type") + } +} + +func TestMarshalStructNullInt64ID(t *testing.T) { + i32 := &NullInt64ID{ + ID: sql.NullInt64{ + Int64: 456, + Valid: true, + }, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, i32); err != nil { + t.Fatal(err) + } + + resp := new(OnePayload) + if err := json.Unmarshal(out.Bytes(), resp); err != nil { + t.Fatal(err) + } + + data := resp.Data + + if data.ID != "456" { + t.Fatalf("Error marshalling id") + } + + if data.Type != "null-int64-id" { + t.Fatal("Error marshalling type") + } +} + +func TestMarshalStructNullFloat64ID(t *testing.T) { + i32 := &NullFloat64ID{ + ID: sql.NullFloat64{ + Float64: 12345678.12345678, + Valid: true, + }, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, i32); err != nil { + t.Fatal(err) + } + + resp := new(OnePayload) + if err := json.Unmarshal(out.Bytes(), resp); err != nil { + t.Fatal(err) + } + + data := resp.Data + + if data.ID != "12345678.12345678" { + t.Fatal("Error marshalling id") + } + + if data.Type != "null-float64-id" { + t.Fatal("Error marshalling type") + } +} + func TestSupportsLinkable(t *testing.T) { testModel := &Blog{ ID: 5, From 25eb6027d2496fbccbe7df8a5ffabd25b1f3c1cf Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Fri, 2 Apr 2021 18:01:28 +0100 Subject: [PATCH 26/28] tests(NullStringID): remove ComputedAtISO field --- models_test.go | 15 +++++++-------- request_test.go | 16 ++++++---------- response_test.go | 25 ++++++++----------------- 3 files changed, 21 insertions(+), 35 deletions(-) diff --git a/models_test.go b/models_test.go index 39e640a5..e205bfdd 100644 --- a/models_test.go +++ b/models_test.go @@ -34,14 +34,13 @@ type Timestamp struct { } type NullStringID struct { - ID sql.NullString `jsonapi:"primary,null-string-id"` - Periodic sql.NullBool `jsonapi:"attr,periodic,omitempty"` - Name sql.NullString `jsonapi:"attr,name,omitempty"` - Value sql.NullFloat64 `jsonapi:"attr,value,omitempty"` - Decimal sql.NullInt32 `jsonapi:"attr,decimal,omitempty"` - Fractional sql.NullInt64 `jsonapi:"attr,fractional,omitempty"` - ComputedAt sql.NullTime `jsonapi:"attr,computed_at,omitempty"` - ComputedAtISO sql.NullTime `jsonapi:"attr,computed_at_iso,iso8601,omitempty"` + ID sql.NullString `jsonapi:"primary,null-string-id"` + Periodic sql.NullBool `jsonapi:"attr,periodic,omitempty"` + Name sql.NullString `jsonapi:"attr,name,omitempty"` + Value sql.NullFloat64 `jsonapi:"attr,value,omitempty"` + Decimal sql.NullInt32 `jsonapi:"attr,decimal,omitempty"` + Fractional sql.NullInt64 `jsonapi:"attr,fractional,omitempty"` + ComputedAt sql.NullTime `jsonapi:"attr,computed_at,omitempty,iso8601"` } type NullInt32ID struct { diff --git a/request_test.go b/request_test.go index afa69e02..2f9092cf 100644 --- a/request_test.go +++ b/request_test.go @@ -218,13 +218,12 @@ func TestUnmarshalToStructNullStringID(t *testing.T) { "type": "null-string-id", "id": "314", "attributes": map[string]interface{}{ - "periodic": false, - "name": "Pi", - "value": 3.1415926535897932, - "decimal": 3, - "fractional": 1415926535897932, - "computed_at": 1615734000, - "computed_at_iso": "2021-03-14T15:00:00Z", + "periodic": false, + "name": "Pi", + "value": 3.1415926535897932, + "decimal": 3, + "fractional": 1415926535897932, + "computed_at": "2021-03-14T15:00:00Z", }, }, } @@ -259,9 +258,6 @@ func TestUnmarshalToStructNullStringID(t *testing.T) { if !pi.ComputedAt.Time.Equal(time.Unix(1615734000, 0)) { t.Fatalf("Error unmarshalling to sql.NullTime") } - if !pi.ComputedAtISO.Time.Equal(time.Unix(1615734000, 0)) { - t.Fatalf("Error unmarshalling to sql.NullTime") - } } func TestUnmarshalToStructNullInt32ID(t *testing.T) { diff --git a/response_test.go b/response_test.go index 160e0061..73c43c61 100644 --- a/response_test.go +++ b/response_test.go @@ -609,14 +609,13 @@ func TestMarshalStructNullStringID_Zero_Invalid(t *testing.T) { func TestMarshalStructNullStringID_Zero_Valid(t *testing.T) { pi := &NullStringID{ - ID: sql.NullString{Valid: true}, - Periodic: sql.NullBool{Valid: true}, - Name: sql.NullString{Valid: true}, - Value: sql.NullFloat64{Valid: true}, - Decimal: sql.NullInt32{Valid: true}, - Fractional: sql.NullInt64{Valid: true}, - ComputedAt: sql.NullTime{Valid: true}, - ComputedAtISO: sql.NullTime{Valid: true}, + ID: sql.NullString{Valid: true}, + Periodic: sql.NullBool{Valid: true}, + Name: sql.NullString{Valid: true}, + Value: sql.NullFloat64{Valid: true}, + Decimal: sql.NullInt32{Valid: true}, + Fractional: sql.NullInt64{Valid: true}, + ComputedAt: sql.NullTime{Valid: true}, } out := bytes.NewBuffer(nil) @@ -664,10 +663,6 @@ func TestMarshalStructNullStringID_Zero_Valid(t *testing.T) { if _, ok := attributes["computed_at"]; ok { t.Fatal("Was expecting data.attributes.computed_at to be omitted") } - - if _, ok := attributes["computed_at_iso"]; ok { - t.Fatal("Was expecting data.attributes.computed_at_iso to be omitted") - } } func TestMarshalStructNullStringID(t *testing.T) { @@ -700,10 +695,6 @@ func TestMarshalStructNullStringID(t *testing.T) { Time: time.Date(2021, 3, 14, 15, 0, 0, 0, time.UTC), Valid: true, }, - ComputedAtISO: sql.NullTime{ - Time: time.Date(2021, 3, 14, 15, 0, 0, 0, time.UTC), - Valid: true, - }, } out := bytes.NewBuffer(nil) @@ -742,7 +733,7 @@ func TestMarshalStructNullStringID(t *testing.T) { t.Fatalf("Error marshalling to sql.NullInt32") } - if data.Attributes["computed_at_iso"] != "2021-03-14T15:00:00Z" { + if data.Attributes["computed_at"] != "2021-03-14T15:00:00Z" { t.Fatalf("Error marshalling to sql.NullTime") } } From 963e8ff2405375b1f0c01459fbf175a67883facc Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Fri, 2 Apr 2021 18:04:53 +0100 Subject: [PATCH 27/28] fix(response): ensure sql.Null* type values are set to `nil` if they're invalid and the omitted tag isn't set --- response.go | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/response.go b/response.go index 7598c837..e9c91642 100644 --- a/response.go +++ b/response.go @@ -422,28 +422,48 @@ func resolveNodeAttribute(node *Node, fieldValue reflect.Value, args []string) * } // Handle remaining sql.Null* types - if nBool, ok := fieldValue.Interface().(sql.NullBool); ok { - node.Attributes[args[1]] = nBool.Bool + if boo, ok := fieldValue.Interface().(sql.NullBool); ok { + if boo.Valid { + node.Attributes[args[1]] = boo.Bool + } else { + node.Attributes[args[1]] = nil + } break } if str, ok := fieldValue.Interface().(sql.NullString); ok { - node.Attributes[args[1]] = str.String + if str.Valid { + node.Attributes[args[1]] = str.String + } else { + node.Attributes[args[1]] = nil + } break } if f64, ok := fieldValue.Interface().(sql.NullFloat64); ok { - node.Attributes[args[1]] = f64.Float64 + if f64.Valid { + node.Attributes[args[1]] = f64.Float64 + } else { + node.Attributes[args[1]] = nil + } break } if i32, ok := fieldValue.Interface().(sql.NullInt32); ok { - node.Attributes[args[1]] = i32.Int32 + if i32.Valid { + node.Attributes[args[1]] = i32.Int32 + } else { + node.Attributes[args[1]] = nil + } break } if i64, ok := fieldValue.Interface().(sql.NullInt64); ok { - node.Attributes[args[1]] = i64.Int64 + if i64.Valid { + node.Attributes[args[1]] = i64.Int64 + } else { + node.Attributes[args[1]] = nil + } break } From 1e54991eaad210b848a4cc52f5c31f3a65afbf72 Mon Sep 17 00:00:00 2001 From: Quetzy Garcia Date: Fri, 2 Apr 2021 18:06:11 +0100 Subject: [PATCH 28/28] tests(response): add coverage for invalid sql.Null* types when the omitempty is not set --- models_test.go | 10 ++++ response_test.go | 151 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/models_test.go b/models_test.go index e205bfdd..c86fcb36 100644 --- a/models_test.go +++ b/models_test.go @@ -55,6 +55,16 @@ type NullFloat64ID struct { ID sql.NullFloat64 `jsonapi:"primary,null-float64-id"` } +type Float struct { + ID sql.NullString `jsonapi:"primary,float"` + Periodic sql.NullBool `jsonapi:"attr,periodic"` + Name sql.NullString `jsonapi:"attr,name"` + Value sql.NullFloat64 `jsonapi:"attr,value"` + Decimal sql.NullInt32 `jsonapi:"attr,decimal"` + Fractional sql.NullInt64 `jsonapi:"attr,fractional"` + ComputedAt sql.NullTime `jsonapi:"attr,computed_at"` +} + type Car struct { ID *string `jsonapi:"primary,cars"` Make *string `jsonapi:"attr,make,omitempty"` diff --git a/response_test.go b/response_test.go index 73c43c61..f6ee4b9a 100644 --- a/response_test.go +++ b/response_test.go @@ -825,6 +825,157 @@ func TestMarshalStructNullFloat64ID(t *testing.T) { } } +func TestMarshalStructPi_Zero_Invalid(t *testing.T) { + pi := new(Float) + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, pi); err != nil { + t.Fatal(err) + } + + var jsonData map[string]interface{} + + if err := json.Unmarshal(out.Bytes(), &jsonData); err != nil { + t.Fatal(err) + } + data := jsonData["data"].(map[string]interface{}) + + if data["type"] != "float" { + t.Fatalf("Error marshalling type") + } + + if _, ok := data["attributes"]; !ok { + t.Fatal("Was expecting data.attributes to NOT be omitted") + } +} + +func TestMarshalStructPi_Zero_Valid(t *testing.T) { + pi := &Float{ + ID: sql.NullString{Valid: true}, + Periodic: sql.NullBool{Valid: true}, + Name: sql.NullString{Valid: true}, + Value: sql.NullFloat64{Valid: true}, + Decimal: sql.NullInt32{Valid: true}, + Fractional: sql.NullInt64{Valid: true}, + ComputedAt: sql.NullTime{Valid: true}, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, pi); err != nil { + t.Fatal(err) + } + + var jsonData map[string]interface{} + + if err := json.Unmarshal(out.Bytes(), &jsonData); err != nil { + t.Fatal(err) + } + data := jsonData["data"].(map[string]interface{}) + + if _, ok := data["id"]; ok { + t.Fatal("Was expecting data.id to be omitted") + } + + if data["type"] != "float" { + t.Fatalf("Error marshalling type") + } + + attributes := data["attributes"].(map[string]interface{}) + + if attributes["periodic"] != false { + t.Fatalf("Error marshalling to sql.NullBool: %v", attributes["periodic"]) + } + + if attributes["name"] != "" { + t.Fatal("Error marshalling to sql.NullString") + } + + if attributes["value"] != 0.0 { + t.Fatal("Error marshalling to sql.NullFloat64") + } + + if attributes["decimal"] != 0.0 { + t.Fatalf("Error marshalling to sql.NullInt32") + } + + if attributes["fractional"] != 0.0 { + t.Fatalf("Error marshalling to sql.NullInt64") + } + + if _, ok := attributes["computed_at"]; ok { + t.Fatal("Was expecting data.attributes.computed_at to be omitted") + } +} + +func TestMarshalStructPi(t *testing.T) { + pi := &Float{ + ID: sql.NullString{ + String: "314", + Valid: true, + }, + Periodic: sql.NullBool{ + Bool: false, + Valid: true, + }, + Name: sql.NullString{ + String: "Float", + Valid: true, + }, + Value: sql.NullFloat64{ + Float64: 3.1415926535897932, + Valid: true, + }, + Decimal: sql.NullInt32{ + Int32: 3, + Valid: true, + }, + Fractional: sql.NullInt64{ + Int64: 1415926535897932, + Valid: true, + }, + ComputedAt: sql.NullTime{ + Time: time.Date(2021, 3, 14, 15, 0, 0, 0, time.UTC), + Valid: true, + }, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, pi); err != nil { + t.Fatal(err) + } + + resp := new(OnePayload) + if err := json.Unmarshal(out.Bytes(), resp); err != nil { + t.Fatal(err) + } + + data := resp.Data + + if data.ID != "314" { + t.Fatal("Error marshalling id") + } + + if data.Type != "float" { + t.Fatal("Error marshalling type") + } + + if data.Attributes["periodic"] != false { + t.Fatal("Error marshalling to sql.NullBool") + } + + if data.Attributes["name"] != "Float" { + t.Fatal("Error marshalling to sql.NullString") + } + + if data.Attributes["value"] != 3.1415926535897932 { + t.Fatal("Error marshalling to sql.NullFloat64") + } + + if data.Attributes["decimal"] != 3.0 { + t.Fatalf("Error marshalling to sql.NullInt32") + } +} + func TestSupportsLinkable(t *testing.T) { testModel := &Blog{ ID: 5,