diff --git a/README.md b/README.md index f067488..0e5c9b2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Flatten is a Go library that simplifies the process of converting arbitrarily ne - Support various key styles: dotted, path-like, Rails, or underscores. - Define custom key styles to suit your needs. - Works with both JSON strings and Go structures. +- Ability to flatten only maps and not arrays. ## Installation @@ -81,11 +82,70 @@ flat, err := gojsonflatten.Flatten(nested, "", flatten.RailsStyle, 3) // } ``` +### Flatten Go Maps without Arrays + +You can also flatten Go maps while preserving arrays as it is like this:: + +```GO + +import ( + "github.com/bhataprameya/gojsonflatten" +) + +nested := map[string]interface{}{ + "a": "b", + "c": map[string]interface{}{ + "d": "e", + "f": "g", + }, + "z": []interface{}{"one", "two"}, +} + +flat, err := gojsonflatten.FlattenNoArray(nested, "", flatten.RailsStyle, 3) + +// Output: +// { +// "a": "b", +// "c[d]": "e", +// "c[f]": "g", +// "z": []interface{}{"one", "two"} +// } +``` + +### Flatten JSON Strings without Arrays + +You can flatten JSON strings while preserving arrays using FlattenNoArray: + +```GO +import ( + "github.com/bhataprameya/gojsonflatten" +) + +nested := `{ + "one": { + "two": [ + "2a", + "2b" + ] + }, + "side": "value" +}` + +flat, err := gojsonflatten.FlattenStringNoArray(nested, "", flatten.DotStyle, -1) + +// Output: +// { +// "one.two": ["2a", "2b"], +// "side": "value" +// } + +``` + ## Custom Key Style You can even define a custom key style for flattening: -```go +```GO import ( "github.com/bhataprameya/gojsonflatten" ) @@ -105,7 +165,7 @@ flat, err := gojsonflatten.FlattenString(nested, "", doubleDash, 5) You can even define a custom key style for flattening: -```go +```GO import ( "github.com/bhataprameya/gojsonflatten" ) diff --git a/flatten.go b/flatten.go index 13cffb9..d850786 100644 --- a/flatten.go +++ b/flatten.go @@ -32,6 +32,26 @@ var ( // Flatten generates a flat map from a nested map with a specified depth. func Flatten(nested map[string]interface{}, prefix string, style SeparatorStyle, depth int) (map[string]interface{}, error) { + return flattenInternal(nested, prefix, style, depth, false) +} + +// FlattenString generates a flat JSON map from a nested JSON string with a specified depth. +func FlattenString(nestedString, prefix string, style SeparatorStyle, depth int) (string, error) { + return flattenStringInternal(nestedString, prefix, style, depth, false) +} + +// FlattenNoArray generates a flat map from a nested map with a specified depth, preserving arrays as strings. +func FlattenNoArray(nested map[string]interface{}, prefix string, style SeparatorStyle, depth int) (map[string]interface{}, error) { + return flattenInternal(nested, prefix, style, depth, true) +} + +// FlattenStringNoArray generates a flat JSON map from a nested JSON string with a specified depth, preserving arrays as strings. +func FlattenStringNoArray(nestedString, prefix string, style SeparatorStyle, depth int) (string, error) { + return flattenStringInternal(nestedString, prefix, style, depth, true) +} + +// flattenInternal generates a flat map from a nested map with a specified depth, optionally preserving arrays as strings. +func flattenInternal(nested map[string]interface{}, prefix string, style SeparatorStyle, depth int, preserveArray bool) (map[string]interface{}, error) { if depth == 0 { return nested, nil } else if depth > 0 { @@ -39,7 +59,7 @@ func Flatten(nested map[string]interface{}, prefix string, style SeparatorStyle, } flatmap := make(map[string]interface{}) - err := flatten(true, flatmap, nested, prefix, style, depth) + err := flatten(true, flatmap, nested, prefix, style, depth, preserveArray) if err != nil { return nil, err } @@ -47,8 +67,8 @@ func Flatten(nested map[string]interface{}, prefix string, style SeparatorStyle, return flatmap, nil } -// FlattenString generates a flat JSON map from a nested JSON string with a specified depth. -func FlattenString(nestedString, prefix string, style SeparatorStyle, depth int) (string, error) { +// flattenStringInternal generates a flat JSON map from a nested JSON string with a specified depth, optionally preserving arrays as strings. +func flattenStringInternal(nestedString, prefix string, style SeparatorStyle, depth int, preserveArray bool) (string, error) { if !isJsonMap.MatchString(nestedString) { return "", ErrNotValidJsonInput } @@ -59,7 +79,7 @@ func FlattenString(nestedString, prefix string, style SeparatorStyle, depth int) return "", err } - flatmap, err := Flatten(nested, prefix, style, depth) + flatmap, err := flattenInternal(nested, prefix, style, depth, preserveArray) if err != nil { return "", err } @@ -73,7 +93,7 @@ func FlattenString(nestedString, prefix string, style SeparatorStyle, depth int) } // flatten recursively processes nested structures and flattens them. -func flatten(top bool, flatMap map[string]interface{}, nested interface{}, prefix string, style SeparatorStyle, depth int) error { +func flatten(top bool, flatMap map[string]interface{}, nested interface{}, prefix string, style SeparatorStyle, depth int, keepArrays bool) error { if depth == 0 { // If the desired depth is reached, add the prefix and nested value to the flat map. flatMap[prefix] = nested @@ -85,7 +105,7 @@ func flatten(top bool, flatMap map[string]interface{}, nested interface{}, prefi switch v.(type) { case map[string]interface{}, []interface{}: // If the value is a nested map or slice, continue flattening recursively. - if err := flatten(false, flatMap, v, newKey, style, depth-1); err != nil { + if err := flatten(false, flatMap, v, newKey, style, depth-1, keepArrays); err != nil { return err } default: @@ -104,10 +124,14 @@ func flatten(top bool, flatMap map[string]interface{}, nested interface{}, prefi assign(newKey, v) } case []interface{}: - for i, v := range nested { - newKey := enkey(top, prefix, strconv.Itoa(i), style) - // Process and assign the index-value pair. - assign(newKey, v) + if !keepArrays { + for i, v := range nested { + newKey := enkey(top, prefix, strconv.Itoa(i), style) + // Process and assign the index-value pair. + assign(newKey, v) + } + } else { + flatMap[prefix] = nested } default: return ErrNotValidInput diff --git a/flatten_test.go b/flatten_test.go index bf148ef..dd78c39 100644 --- a/flatten_test.go +++ b/flatten_test.go @@ -666,3 +666,630 @@ func TestFlattenWithVaryingDepth(t *testing.T) { }) } } + +func TestFlattenNoArray(t *testing.T) { + cases := []struct { + test string + want map[string]interface{} + prefix string + style SeparatorStyle + depth int + }{ + // Test case 1 + { + // JSON input + `{ + "foo": { + "jim":"bean" + }, + "fee": "bar", + "n1": { + "alist": [ + "a", + "b", + "c", + { + "d": "other", + "e": "another" + } + ] + }, + "number": 1.4567, + "bool": true + }`, + // Expected flattened result + map[string]interface{}{ + "foo.jim": "bean", + "fee": "bar", + "n1.alist": []interface{}{"a", "b", "c", map[string]interface{}{"d": "other", "e": "another"}}, + "number": 1.4567, + "bool": true, + }, + // Prefix, SeparatorStyle, and depth + "", + DotStyle, + -1, + }, + // Test case 2 + { + // JSON input + `{ + "foo": { + "jim":"bean" + }, + "fee": "bar", + "n1": { + "alist": [ + "a", + "b", + "c", + { + "d": "other", + "e": "another" + } + ] + } + }`, + // Expected flattened result + map[string]interface{}{ + "foo[jim]": "bean", + "fee": "bar", + "n1[alist]": []interface{}{"a", "b", "c", map[string]interface{}{"d": "other", "e": "another"}}, + }, + // Prefix, SeparatorStyle, and depth + "", + RailsStyle, + -1, + }, + // Test case 3 + { + // JSON input + `{ + "foo": { + "jim":"bean" + }, + "fee": "bar", + "n1": { + "alist": [ + "a", + "b", + "c", + { + "d": "other", + "e": "another" + } + ] + }, + "number": 1.4567, + "bool": true + }`, + // Expected flattened result + map[string]interface{}{ + "foo/jim": "bean", + "fee": "bar", + "n1/alist": []interface{}{"a", "b", "c", map[string]interface{}{"d": "other", "e": "another"}}, + "number": 1.4567, + "bool": true, + }, + // Prefix, SeparatorStyle, and depth + "", + PathStyle, + -1, + }, + // Test case 4 + { + // JSON input + `{ "a": { "b": "c" }, "e": "f" }`, + // Expected flattened result + map[string]interface{}{ + "p:a.b": "c", + "p:e": "f", + }, + // Prefix, SeparatorStyle, and depth + "p:", + DotStyle, + -1, + }, + // Test case 5 + { + // JSON input + `{ + "foo": { + "jim":"bean" + }, + "fee": "bar", + "n1": { + "alist": [ + "a", + "b", + "c", + { + "d": "other", + "e": "another" + } + ] + }, + "number": 1.4567, + "bool": true + }`, + // Expected flattened result + map[string]interface{}{ + "foo_jim": "bean", + "fee": "bar", + "n1_alist": []interface{}{"a", "b", "c", map[string]interface{}{"d": "other", "e": "another"}}, + "number": 1.4567, + "bool": true, + }, + // Prefix, SeparatorStyle, and depth + "", + UnderscoreStyle, + -1, + }, + // Test case 6 + { + // JSON input + `{ + "foo": { + "jim":"bean" + }, + "fee": "bar", + "n1": { + "alist": [ + "a", + "b", + "c", + { + "d": "other", + "e": "another" + } + ] + }, + "number": 1.4567, + "bool": true + }`, + // Expected flattened result + map[string]interface{}{ + "flag-foo_jim": "bean", + "flag-fee": "bar", + "flag-n1_alist": []interface{}{"a", "b", "c", map[string]interface{}{"d": "other", "e": "another"}}, + "flag-number": 1.4567, + "flag-bool": true, + }, + // Prefix, SeparatorStyle, and depth + "flag-", + UnderscoreStyle, + -1, + }, + } + + for i, test := range cases { + t.Run(fmt.Sprintf("test: %v", i), func(t *testing.T) { + var m interface{} + err := json.Unmarshal([]byte(test.test), &m) + assert.NoError(t, err) + got, err := FlattenNoArray(m.(map[string]interface{}), test.prefix, test.style, test.depth) + assert.NoError(t, err) + deepEquals(t, i, got, test.want) + }) + } +} + +func TestFlattenStringNoArray(t *testing.T) { + cases := []struct { + test string + want string + prefix string + style SeparatorStyle + depth int + err error + }{ + // Test case 1 + { + // JSON input + `{ "a": "b" }`, + // Expected flattened result + `{ "a": "b" }`, + // Prefix, SeparatorStyle, and depth + "", + DotStyle, + -1, + nil, + }, + // Test case 2 + { + // JSON input + `{ "a": { "b" : { "c" : { "d" : "e" } } }, "number": 1.4567, "bool": true }`, + // Expected flattened result + `{ "a.b.c.d": "e", "bool": true, "number": 1.4567 }`, + // Prefix, SeparatorStyle, and depth + "", + DotStyle, + -1, + nil, + }, + // Test case 3 + { + // JSON input + `{ "a": { "b" : { "c" : { "d" : "e" } } }, "number": 1.4567, "bool": true }`, + // Expected flattened result + `{ "a/b/c/d": "e", "bool": true, "number": 1.4567 }`, + // Prefix, SeparatorStyle, and depth + "", + PathStyle, + -1, + nil, + }, + // Test case 4 + { + // JSON input + `{ "a": { "b" : { "c" : { "d" : "e" } } } }`, + // Expected flattened result + `{ "a--b--c--d": "e" }`, + // Prefix, SeparatorStyle, and depth + "", + SeparatorStyle{Middle: "--"}, // emdash + -1, + nil, + }, + // Test case 5 + { + // JSON input + `{ "a": { "b" : { "c" : { "d" : "e" } } } }`, + // Expected flattened result + `{ "a(b)(c)(d)": "e" }`, + // Prefix, SeparatorStyle, and depth + "", + SeparatorStyle{Before: "(", After: ")"}, // paren groupings + -1, + nil, + }, + // Test case 6 + { + // JSON input with leading whitespace + ` + { "a": { "b" : { "c" : { "d" : "e" } } } }`, + // Expected flattened result + `{ "a(b)(c)(d)": "e" }`, + // Prefix, SeparatorStyle, and depth + "", + SeparatorStyle{Before: "(", After: ")"}, // paren groupings + -1, + nil, + }, + // Test case 7 - Invalid JSON input + { + // Invalid JSON input + `[ "a": { "b": "c" }, "d" ]`, + // Expected result + `bogus`, + // Prefix, SeparatorStyle, and depth + "", + PathStyle, + -1, + ErrNotValidJsonInput, + }, + // Test case 8 - Invalid JSON input + { + // Empty JSON input + ``, + // Expected result + `bogus`, + // Prefix, SeparatorStyle, and depth + "", + PathStyle, + -1, + ErrNotValidJsonInput, + }, + // Test case 9 - Invalid JSON input + { + // Invalid JSON input (missing quotes) + `astring`, + // Expected result + `bogus`, + // Prefix, SeparatorStyle, and depth + "", + PathStyle, + -1, + ErrNotValidJsonInput, + }, + // Test case 10 - Invalid JSON input + { + // Invalid JSON input (not an object) + `false`, + // Expected result + `bogus`, + // Prefix, SeparatorStyle, and depth + "", + PathStyle, + -1, + ErrNotValidJsonInput, + }, + // Test case 11 - Invalid JSON input + { + // Invalid JSON input (not an object) + `42`, + // Expected result + `bogus`, + // Prefix, SeparatorStyle, and depth + "", + PathStyle, + -1, + ErrNotValidJsonInput, + }, + // Test case 12 - Invalid JSON input (null) + { + // JSON input with null + `null`, + // Expected result (error) + `{}`, + // Prefix, SeparatorStyle, and depth + "", + PathStyle, + -1, + ErrNotValidJsonInput, + }, + // Test case 13 + { + // JSON input + `{ "a": { "b" : { "c" : { "d" : "e" } } }, "number": 1.4567, "bool": true }`, + // Expected flattened result + `{ "flag-a.b.c.d": "e", "flag-bool": true, "flag-number": 1.4567 }`, + // Prefix, SeparatorStyle, and depth + "flag-", + DotStyle, + -1, + nil, + }, + // Test case 14 + { + // JSON input + `{"one":{"two":["2a","2b"]},"side":"value"}`, + // Expected flattened result + `{"one.two":["2a","2b"],"side":"value"}`, + // Prefix, SeparatorStyle, and depth + "", + DotStyle, + -1, + nil, + }, + } + nixws := func(r rune) rune { + if unicode.IsSpace(r) { + return -1 + } + return r + } + + for i, test := range cases { + t.Run(fmt.Sprintf("test: %v", i), func(t *testing.T) { + got, err := FlattenStringNoArray(test.test, test.prefix, test.style, test.depth) + errors.Is(err, test.err) + if err == nil { + assert.Equal(t, got, strings.Map(nixws, test.want)) + } + }) + } +} + +func TestFlattenNoArrayWithVaryingDepth(t *testing.T) { + cases := []struct { + test string + want map[string]interface{} + prefix string + style SeparatorStyle + depth int + }{ + // Test data for depth 1 + { + // JSON input + `{ + "foo": { + "bar": { + "baz": "qux" + } + }, + "quux": 789.1 + }`, + // Expected flattened result + map[string]interface{}{ + "foo.bar": map[string]interface{}{ + "baz": "qux", + }, + "quux": 789.1, + }, + // Prefix, SeparatorStyle, and depth + "", + DotStyle, + 1, + }, + // Test data for depth 2 + { + // JSON input + `{ + "a": { + "b": { + "c": { + "d": { + "e": "f" + } + } + } + }, + "g": "h" + }`, + // Expected flattened result + map[string]interface{}{ + "a_b_c": map[string]interface{}{ + "d": map[string]interface{}{ + "e": "f", + }, + }, + "g": "h", + }, + // Prefix, SeparatorStyle, and depth + "", + UnderscoreStyle, + 2, + }, + + // Test data for depth 3 + { + // JSON input + `{ + "a": { + "b": { + "c": { + "d": { + "e": "f" + } + } + } + }, + "g": "h" + }`, + // Expected flattened result + map[string]interface{}{ + "a/b/c/d": map[string]interface{}{ + "e": "f", + }, + "g": "h", + }, + // Prefix, SeparatorStyle, and depth + "", + PathStyle, + 3, + }, + + // Test data for depth 4 + { + // JSON input + `{ + "a": { + "b": { + "c": { + "d": { + "e": 22.2 + } + } + } + }, + "g": "h" + }`, + // Expected flattened result + map[string]interface{}{ + "a/b/c/d/e": 22.2, + "g": "h", + }, + // Prefix, SeparatorStyle, and depth + "", + PathStyle, + 4, + }, + // Test data for depth 5 + { + // JSON input + `{ + "a": { + "b": { + "c": { + "d": { + "e": 22.2 + } + } + } + }, + "g": "h" + }`, + // Expected flattened result + map[string]interface{}{ + "test_a[b][c][d][e]": 22.2, + "test_g": "h", + }, + // Prefix, SeparatorStyle, and depth + "test_", + RailsStyle, + 5, + }, + // Test data for depth 2 with array + { + // JSON input + `{ + "foo": { + "jim":"bean" + }, + "fee": "bar", + "n1": { + "alist": [ + "a", + "b", + "c", + { + "d": "other", + "e": "another" + } + ] + }, + "number": 1.4567, + "bool": true + }`, + // Expected flattened result + map[string]interface{}{ + "foo.jim": "bean", + "fee": "bar", + "n1.alist": []interface{}{"a", "b", "c", map[string]interface{}{"d": "other", "e": "another"}}, + "number": 1.4567, + "bool": true, + }, + // Prefix, SeparatorStyle, and depth + "", + DotStyle, + 2, + }, + // Test data for depth 2 with array and prefix + { + // JSON input + `{ + "foo": { + "jim":"bean" + }, + "fee": "bar", + "n1": { + "alist": [ + "a", + "b", + "c", + { + "d": "other", + "e": "another" + } + ] + }, + "number": 1.4567, + "bool": true + }`, + // Expected flattened result + map[string]interface{}{ + "aa_foo.jim": "bean", + "aa_fee": "bar", + "aa_n1.alist": []interface{}{"a", "b", "c", map[string]interface{}{"d": "other", "e": "another"}}, + "aa_number": 1.4567, + "aa_bool": true, + }, + // Prefix, SeparatorStyle, and depth + "aa_", + DotStyle, + 2, + }, + } + + for i, test := range cases { + t.Run(fmt.Sprintf("test: %v, depth: %d", i, test.depth), func(t *testing.T) { + var m interface{} + err := json.Unmarshal([]byte(test.test), &m) + assert.NoError(t, err) + got, err := FlattenNoArray(m.(map[string]interface{}), test.prefix, test.style, test.depth) + assert.NoError(t, err) + deepEquals(t, i, got, test.want) + }) + } +} diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fa4b6e6 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=