Skip to content

Commit 8df8d10

Browse files
authored
Merge pull request #4 from Quanare/support-nested-structs
Support nested structs (#1)
2 parents 4cb8d79 + d65553b commit 8df8d10

File tree

8 files changed

+315
-42
lines changed

8 files changed

+315
-42
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.idea
2+
vendor

README.md

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,16 @@ Suppose we have a `Person` model
4545

4646
```go
4747
type Person struct {
48-
FirstName string `json:"first_name"`
49-
LastName string `json:"last_name"`
50-
Age int `json:"age"`
48+
FirstName string `json:"first_name"`
49+
LastName string `json:"last_name"`
50+
Age int `json:"age"`
51+
Address Address `json:"address"`
52+
}
53+
54+
type Address struct {
55+
Country string `json:"country"`
56+
City string `json:"city"`
57+
AddressLine string `json:"address_line"`
5158
}
5259
```
5360

@@ -56,7 +63,11 @@ and a client comes along and makes a `POST` request with this JSON.
5663
```json
5764
{
5865
"first_name": "Jessie",
59-
"age": "26"
66+
"age": "26",
67+
"address": {
68+
"country": "Example country",
69+
"city": "Example city"
70+
}
6071
}
6172
```
6273

@@ -75,7 +86,7 @@ After comparing we now have a `CompareResults` instance stored in `results`.
7586
```go
7687
type CompareResults struct {
7788
MismatchedFields []FieldMismatch
78-
MissingFields []string
89+
MissingFields []FieldMissing
7990
}
8091
```
8192

@@ -93,18 +104,31 @@ import (
93104
schema "github.com/Kangaroux/go-map-schema"
94105
)
95106

107+
// Person is the model we are using.
96108
type Person struct {
97-
FirstName string `json:"first_name"`
98-
LastName string `json:"last_name"`
99-
Age int `json:"age"`
109+
FirstName string `json:"first_name"`
110+
LastName string `json:"last_name"`
111+
Age int `json:"age"`
112+
Address Address `json:"address"`
113+
}
114+
115+
// Address is the model we are using as nested for Person.
116+
type Address struct {
117+
Country string `json:"country"`
118+
City string `json:"city"`
119+
AddressLine string `json:"address_line"`
100120
}
101121

102122
func main() {
103123
// The JSON payload (src).
104124
payload := `{
105-
"first_name": "Jessie",
106-
"age": "26"
107-
}`
125+
"first_name": "Jessie",
126+
"age": "26",
127+
"address": {
128+
"country": "Example country",
129+
"city": "Example city"
130+
}
131+
}`
108132
m := make(map[string]interface{})
109133
json.Unmarshal([]byte(payload), &m)
110134

@@ -120,7 +144,7 @@ func main() {
120144
### Output
121145

122146
```
123-
missing fields: [last_name]
147+
missing fields: [last_name address.address_line]
124148
mismatched fields: [expected "age" to be a int but it's a string]
125149
```
126150

examples/required-fields/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func main() {
4949
m := make(map[string]string)
5050

5151
for _, f := range r.MissingFields {
52-
m[f] = "this field is required"
52+
m[f.String()] = "this field is required"
5353
}
5454

5555
resp.OK = false

examples/type-errors/main.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,17 @@ import (
99

1010
// Person is the model we are using.
1111
type Person struct {
12-
FirstName string `json:"first_name"`
13-
LastName string `json:"last_name"`
14-
Age int `json:"age"`
12+
FirstName string `json:"first_name"`
13+
LastName string `json:"last_name"`
14+
Age int `json:"age"`
15+
Address Address `json:"address"`
16+
}
17+
18+
// Address is the model we are using as nested for Person.
19+
type Address struct {
20+
Country string `json:"country"`
21+
City string `json:"city"`
22+
AddressLine string `json:"address_line"`
1523
}
1624

1725
// Response is used to generate a JSON response.
@@ -23,7 +31,11 @@ type Response struct {
2331
func main() {
2432
input := `{
2533
"first_name": "Jessie",
26-
"age": "26"
34+
"age": "26",
35+
"address": {
36+
"country": "Example country",
37+
"city": "Example city"
38+
}
2739
}`
2840
m := make(map[string]interface{})
2941
p := Person{}

lang.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,14 @@ func TypeNameWithArticle(t string) string {
7474
return "a " + t
7575
}
7676
}
77+
78+
// FieldNameWithPath returns the field name including the path in the following format: parent1.parent2.name
79+
func FieldNameWithPath(f string, path []string) string {
80+
b := strings.Builder{}
81+
for _, p := range path {
82+
b.WriteString(p + ".")
83+
}
84+
b.WriteString(f)
85+
86+
return b.String()
87+
}

lang_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,38 @@ func TestTypeNameWithArticle(t *testing.T) {
184184
require.Equal(t, test.expected, actual)
185185
}
186186
}
187+
188+
// Tests that FieldNameWithPath returns the expected result.
189+
func TestFieldNameWithPath(t *testing.T) {
190+
191+
tests := []struct {
192+
name string
193+
path []string
194+
expected string
195+
}{
196+
{
197+
name: "user",
198+
expected: "user",
199+
},
200+
{
201+
name: "cat",
202+
path: []string{},
203+
expected: "cat",
204+
},
205+
{
206+
name: "email",
207+
path: []string{"user"},
208+
expected: "user.email",
209+
},
210+
{
211+
name: "is_primary",
212+
path: []string{"user", "contacts", "email"},
213+
expected: "user.contacts.email.is_primary",
214+
},
215+
}
216+
217+
for _, test := range tests {
218+
actual := schema.FieldNameWithPath(test.name, test.path)
219+
require.Equal(t, test.expected, actual)
220+
}
221+
}

schema.go

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ type CompareResults struct {
1313
MismatchedFields []FieldMismatch
1414

1515
// MissingFields is a list of JSON field names which were not in src.
16-
MissingFields []string
16+
MissingFields []FieldMissing
1717
}
1818

1919
// Errors returns a MismatchError containing the type errors. If there were no
@@ -32,6 +32,20 @@ func (cr *CompareResults) Errors() error {
3232
return MismatchError(m)
3333
}
3434

35+
type FieldMissing struct {
36+
// Field is the JSON name of the field.
37+
Field string
38+
39+
// Path is the full path to the field.
40+
Path []string
41+
}
42+
43+
// String returns the field name with its path.
44+
// e.g: "Cat.Foo"
45+
func (f FieldMissing) String() string {
46+
return FieldNameWithPath(f.Field, f.Path)
47+
}
48+
3549
// FieldMismatch represents a type mismatch between a struct field and a map field.
3650
type FieldMismatch struct {
3751
// Field is the JSON name of the field.
@@ -42,6 +56,9 @@ type FieldMismatch struct {
4256

4357
// Actual is the actual type (type of the src field).
4458
Actual string
59+
60+
// Path is the full path to the field.
61+
Path []string
4562
}
4663

4764
// Message returns the field mismatch error as a string.
@@ -55,12 +72,12 @@ func (f FieldMismatch) Message() string {
5572
}
5673

5774
// Message returns the field mismatch error as a string, and includes the field name
58-
// in the message.
59-
// e.g: "expected Foo to be an int but it's a string"
75+
// with its path in the message.
76+
// e.g: "expected Cat.Foo to be an int but it's a string"
6077
func (f FieldMismatch) MessageWithField() string {
6178
return fmt.Sprintf(
6279
`expected "%s" to be %s but it's %s`,
63-
f.Field,
80+
FieldNameWithPath(f.Field, f.Path),
6481
TypeNameWithArticle(f.Expected),
6582
TypeNameWithArticle(f.Actual),
6683
)
@@ -146,7 +163,7 @@ func CompareMapToStruct(dst interface{}, src map[string]interface{}, opts *Compa
146163

147164
results := &CompareResults{
148165
MismatchedFields: []FieldMismatch{},
149-
MissingFields: []string{},
166+
MissingFields: []FieldMissing{},
150167
}
151168

152169
compare(v.Elem().Type(), src, opts, results)
@@ -160,6 +177,7 @@ func CompareMapToStruct(dst interface{}, src map[string]interface{}, opts *Compa
160177
// t points to.
161178
func DefaultCanConvert(t reflect.Type, v reflect.Value) bool {
162179
isPtr := t.Kind() == reflect.Ptr
180+
isStruct := t.Kind() == reflect.Struct
163181
dstType := t
164182

165183
// Check if v is a nil value.
@@ -170,6 +188,12 @@ func DefaultCanConvert(t reflect.Type, v reflect.Value) bool {
170188
// If the dst is a pointer, check if we can convert to the type it's pointing to.
171189
if isPtr {
172190
dstType = t.Elem()
191+
isStruct = t.Elem().Kind() == reflect.Struct
192+
}
193+
194+
// If the dst is a struct, we should check its nested fields.
195+
if isStruct {
196+
return v.Kind() == reflect.Map
173197
}
174198

175199
if !v.Type().ConvertibleTo(dstType) {
@@ -212,6 +236,9 @@ func compare(t reflect.Type, src map[string]interface{}, opts *CompareOpts, resu
212236
continue
213237
}
214238

239+
// If the field is a nested struct also check its fields.
240+
shouldCheckNested := isStructType(f.Type)
241+
215242
if srcField, ok := src[fieldName]; ok {
216243
srcValue := reflect.ValueOf(srcField)
217244

@@ -231,9 +258,47 @@ func compare(t reflect.Type, src map[string]interface{}, opts *CompareOpts, resu
231258
}
232259

233260
results.MismatchedFields = append(results.MismatchedFields, mismatch)
261+
// There is no point to check nested fields if their parent is mismatched.
262+
shouldCheckNested = false
234263
}
235264
} else {
236-
results.MissingFields = append(results.MissingFields, fieldName)
265+
missing := FieldMissing{Field: fieldName}
266+
267+
results.MissingFields = append(results.MissingFields, missing)
268+
// There is no point to check nested fields if their parent is missing.
269+
shouldCheckNested = false
270+
}
271+
272+
if shouldCheckNested {
273+
nested := src[fieldName].(map[string]interface{})
274+
nestedType := f.Type
275+
if f.Type.Kind() == reflect.Ptr {
276+
nestedType = nestedType.Elem()
277+
}
278+
279+
checkNestedFields(nestedType, fieldName, nested, opts, results)
280+
}
281+
}
282+
}
283+
284+
func checkNestedFields(t reflect.Type, fieldName string, src map[string]interface{}, opts *CompareOpts, results *CompareResults) {
285+
// Remember count of fields to check if new errors occured.
286+
mismatchCount := len(results.MismatchedFields)
287+
missingCount := len(results.MissingFields)
288+
289+
compare(t, src, opts, results)
290+
291+
// If there were new mismatched fields, add the current field name to their path.
292+
if mismatchCount != len(results.MismatchedFields) {
293+
for mi := mismatchCount; mi < len(results.MismatchedFields); mi++ {
294+
results.MismatchedFields[mi].Path = append([]string{fieldName}, results.MismatchedFields[mi].Path...)
295+
}
296+
}
297+
298+
// If there were new missing fields, add the current field name to their path.
299+
if missingCount != len(results.MissingFields) {
300+
for mi := missingCount; mi < len(results.MissingFields); mi++ {
301+
results.MissingFields[mi].Path = append([]string{fieldName}, results.MissingFields[mi].Path...)
237302
}
238303
}
239304
}
@@ -262,6 +327,17 @@ func isIntegerType(t reflect.Type) (yes bool, unsigned bool) {
262327
return
263328
}
264329

330+
// isStructType returns whether the type is a struct or a pointer to it.
331+
func isStructType(t reflect.Type) (yes bool) {
332+
switch t.Kind() {
333+
case reflect.Struct:
334+
yes = true
335+
case reflect.Ptr:
336+
yes = t.Elem().Kind() == reflect.Struct
337+
}
338+
return
339+
}
340+
265341
// parseField returns the field's JSON name.
266342
func parseField(f reflect.StructField) (name string, ignore bool) {
267343
tag := f.Tag.Get("json")

0 commit comments

Comments
 (0)