├── .github └── workflows │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── Makefile ├── README.md ├── go.mod ├── go.sum ├── map.go ├── marshalling.go ├── marshalling_test.go ├── scalar.go ├── slice.go ├── value.go └── value_test.go /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Lint 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | 11 | jobs: 12 | 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v4 20 | with: 21 | go-version: '1.23' 22 | 23 | - name: make install-tools 24 | run: make install-tools 25 | 26 | - name: lint 27 | run: make lint 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | 11 | jobs: 12 | 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v4 20 | with: 21 | go-version: '1.23' 22 | 23 | - name: Build 24 | run: make build 25 | 26 | - name: Test 27 | run: go test -race -v ./... 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | 27 | bin 28 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 10m 3 | linters: 4 | enable: 5 | - asciicheck 6 | - bidichk 7 | - bodyclose 8 | - copyloopvar 9 | - decorder 10 | - depguard 11 | - dogsled 12 | - durationcheck 13 | - errcheck 14 | - exhaustive 15 | - forbidigo 16 | - forcetypeassert 17 | - gci 18 | - goconst 19 | - gocritic 20 | - godot 21 | - gofmt 22 | - gofumpt 23 | - mnd 24 | - gosec 25 | - gosimple 26 | - govet 27 | - grouper 28 | - importas 29 | - ineffassign 30 | - lll 31 | - misspell 32 | - nakedret 33 | - nilerr 34 | - nilnil 35 | - noctx 36 | - nolintlint 37 | - prealloc 38 | - predeclared 39 | - promlinter 40 | - revive 41 | - rowserrcheck 42 | - sqlclosecheck 43 | - staticcheck 44 | - stylecheck 45 | - typecheck 46 | - unconvert 47 | - unparam 48 | - unused 49 | - wastedassign 50 | - whitespace 51 | - tenv 52 | issues: 53 | exclude-rules: 54 | - linters: 55 | - gosec 56 | text: "integer overflow conversion" 57 | linters-settings: 58 | goconst: 59 | min-occurrences: 10 60 | depguard: 61 | rules: 62 | Main: 63 | list-mode: lax 64 | allow: 65 | - $all 66 | exhaustive: 67 | # Presence of "default" case in switch statements satisfies exhaustiveness 68 | default-signifies-exhaustive: true 69 | revive: 70 | rules: 71 | - name: unused-parameter 72 | severity: warning 73 | disabled: true 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Jorge Bay 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | go test -v ./... 4 | 5 | .PHONY: lint 6 | lint: 7 | ./bin/golangci-lint run ./... --verbose 8 | 9 | .PHONY: build 10 | build: 11 | go build ./... 12 | 13 | .PHONY: install-tools 14 | GOLANGCI_LINT_VERSION=v1.61.0 15 | install-tools: 16 | curl -sSfL \ 17 | "https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh" \ 18 | | sh -s -- -b bin ${GOLANGCI_LINT_VERSION} 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSONNAV 2 | 3 | jsonnav is a [Go package](https://pkg.go.dev/github.com/jorgebay/jsonnav#section-documentation) for accessing, 4 | navigating and manipulating values from an untyped json document. 5 | 6 | [![Build](https://github.com/jorgebay/jsonnav/actions/workflows/test.yml/badge.svg)](https://github.com/jorgebay/jsonnav/actions/workflows/test.yml) 7 | 8 | ## Features 9 | 10 | - Retrieve values from deeply nested json documents safely. 11 | - Built-in type check functions and conversions. 12 | - Iterate over arrays and objects. 13 | - Supports [GJSON][gjson] syntax for navigating the json document. 14 | - Set or delete values in place. 15 | 16 | ## Installing 17 | 18 | ```shell 19 | go get github.com/jorgebay/jsonnav 20 | ``` 21 | 22 | ## Usage 23 | 24 | ```go 25 | v, err := jsonnav.Unmarshal(`{"name":{"first":"Jimi","last":"Hendrix"},"age":27}`) 26 | v.Get("name").Get("first").String() // "Jimi" 27 | v.Get("name.last").String() // "Hendrix" 28 | v.Get("age").Float() // 27.0 29 | v.Get("path").Get("does").Get("not").Get("exist").Exists() // false 30 | v.Get("path.does.not.exist").Exists() // false 31 | ``` 32 | 33 | It uses [GJSON syntax][gjson] for navigating the json document. 34 | 35 | ### Accessing values that may not exist 36 | 37 | It's safe to access values that may not exist. The library will return a scalar `Value` representation 38 | with `nil` underlying value. 39 | 40 | ```go 41 | v, err := jsonnav.Unmarshal(`{ 42 | "name": {"first": "Jimi", "last": "Hendrix"}, 43 | "instruments": [{"name": "guitar"}] 44 | }`) 45 | v.Get("birth").Get("date").Exists() // false 46 | v.Get("birth.date").Exists() // false 47 | v.Get("instruments").Array().At(0).Get("name").String() // "guitar" 48 | v.Get("instruments.0.name").String() // "guitar" 49 | v.Get("instruments").Array().At(1).Get("name").String() // "" 50 | v.Get("instruments.1.name").String() // "" 51 | ``` 52 | 53 | ### Setting and deleting values 54 | 55 | You can set or delete values in place using `Set()` and `Delete()` methods. 56 | 57 | ```go 58 | v, err := jsonnav.Unmarshal(`{"name":{"first":"Jimi","last":"Hendrix"},"age":27}`) 59 | v.Get("name").Set("middle", "Marshall") 60 | v.Set("birth.date", "1942-11-27") 61 | v.Delete("age") 62 | 63 | v.Get("birth").Get("date").String() // "1942-11-27" 64 | v.Get("name").Get("middle").String() // "Marshall" 65 | ``` 66 | 67 | ### Type checks and conversions 68 | 69 | The library provides built-in functions for type checks and conversions that are safely free of errors and panics. 70 | 71 | #### Type check functions 72 | 73 | ```go 74 | v, err := jsonnav.Unmarshal(`{"name":"Jimi","age":27}`) 75 | v.Get("name").IsString() // true 76 | v.Get("age").IsFloat() // true 77 | v.Get("age").IsBool() // false 78 | v.Get("age").IsObject() // false 79 | v.Get("age").IsArray() // false 80 | v.Get("not_found").IsNull() // true 81 | v.Get("not_found").IsEmpty() // true 82 | ``` 83 | 84 | #### Typed getters 85 | 86 | ```go 87 | v, err := jsonnav.Unmarshal(`{ 88 | "name": "Jimi", 89 | "age": 27, 90 | "instruments": ["guitar"], 91 | "hall_of_fame": true 92 | }`) 93 | v.Get("name").String() // "Jimi" 94 | v.Get("age").Float() // 27.0 95 | v.Get("hall_of_fame").Bool() // true 96 | v.Get("instruments").Array() // a slice of 1 Value with underlying value "guitar" 97 | ``` 98 | 99 | When the value doesn't match the expected type or it does not exist, it will default to a zero value of the 100 | expected type and do conversions for scalars. 101 | 102 | - `String()` returns the string representation of float and bool values, otherwise an empty string. 103 | - `Float()` returns the float representation of string values, for other types it returns 0.0. 104 | - `Bool()` returns the bool representation of string values, for other types it returns false. 105 | - `Array()` returns an empty slice for non-array values. 106 | 107 | ### Iterating over arrays 108 | 109 | You can iterate over arrays using the `Array()` method. 110 | 111 | ```go 112 | v, err := jsonnav.Unmarshal(`{ 113 | "instruments": [ 114 | {"name": "guitar"}, 115 | {"name": "bass"} 116 | ] 117 | }`) 118 | 119 | for _, instrument := range v.Get("instruments").Array() { 120 | fmt.Println(instrument.Get("name").String()) 121 | } 122 | ``` 123 | 124 | You can also collect internal properties of an array using `#` gjson wildcard. 125 | 126 | ```go 127 | for _, instrumentName := range v.Get("instruments.#.name").Array() { 128 | fmt.Println(instrumentName.String()) 129 | } 130 | ``` 131 | 132 | ### Parsing 133 | 134 | The library uses Golang built-in json marshallers. In case you want to use a custom marshaller, you can use 135 | `jsonnav.From[T]()` or `jsonnav.FromAny()` by providing the actual value. 136 | 137 | ```go 138 | v, err := jsonnav.From(map[string]any{"name": "John", "age": 30}) 139 | v.Get("name").String() // "John" 140 | ``` 141 | 142 | ## License 143 | 144 | jsonnav is distributed under [MIT License](https://opensource.org/license/MIT). 145 | 146 | [gjson]: https://github.com/tidwall/gjson/blob/master/SYNTAX.md -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jorgebay/jsonnav 2 | 3 | go 1.23.2 4 | 5 | require github.com/stretchr/testify v1.10.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 6 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /map.go: -------------------------------------------------------------------------------- 1 | package jsonnav 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | // Map represents a JSON object. 9 | type Map struct { 10 | m map[string]any 11 | } 12 | 13 | // Exists returns true if the value is defined. 14 | func (m *Map) Exists() bool { 15 | return true 16 | } 17 | 18 | // IsEmpty returns true if the map does not have any items. 19 | func (m *Map) IsEmpty() bool { 20 | return len(m.m) == 0 21 | } 22 | 23 | // IsNull returns false for maps. 24 | func (*Map) IsNull() bool { 25 | return false 26 | } 27 | 28 | // IsArray returns false for maps. 29 | func (*Map) IsArray() bool { 30 | return false 31 | } 32 | 33 | // IsObject returns true for maps. 34 | func (*Map) IsObject() bool { 35 | return true 36 | } 37 | 38 | // IsString returns false for maps. 39 | func (*Map) IsString() bool { 40 | return false 41 | } 42 | 43 | // IsFloat returns false for maps. 44 | func (*Map) IsFloat() bool { 45 | return false 46 | } 47 | 48 | // IsBool returns false for maps. 49 | func (*Map) IsBool() bool { 50 | return false 51 | } 52 | 53 | // IsInt returns false for maps. 54 | func (m *Map) Bool() bool { 55 | return false 56 | } 57 | 58 | // Float returns 0 for maps. 59 | func (m *Map) Float() float64 { 60 | return 0 61 | } 62 | 63 | // Int returns 0 for maps. 64 | func (m *Map) Int() int64 { 65 | return 0 66 | } 67 | 68 | // String returns an empty string for maps. 69 | func (m *Map) String() string { 70 | return "" 71 | } 72 | 73 | // Value returns the underlying map. 74 | func (m *Map) Value() any { 75 | return m.m 76 | } 77 | 78 | // Get searches json for the specified path. 79 | func (m *Map) Get(path string) Value { 80 | if len(path) == 0 { 81 | panic("invalid zero length") 82 | } 83 | key, remainingPath, _ := strings.Cut(path, ".") 84 | equalityIndex := strings.Index(key, "=") 85 | if equalityIndex != -1 { 86 | // Only string conditions are supported 87 | condition := key[equalityIndex+1:] 88 | key = key[:equalityIndex] 89 | 90 | if !areEqualFromCondition(m.m[key], condition) { 91 | return undefinedScalar 92 | } 93 | 94 | // Condition for map matched, return itself 95 | if remainingPath == "" { 96 | return m 97 | } 98 | return m.Get(remainingPath) 99 | } 100 | 101 | var value Value 102 | if rawValue, ok := m.m[key]; ok { 103 | value = mustToPathValue(rawValue) 104 | } else { 105 | value = undefinedScalar 106 | } 107 | 108 | if remainingPath == "" { 109 | return value 110 | } 111 | 112 | return value.Get(remainingPath) 113 | } 114 | 115 | // areEqualFromCondition determines whether 2 values are equal according to gjson syntax. 116 | func areEqualFromCondition(value any, expected string) bool { 117 | if value == expected { 118 | return true 119 | } 120 | 121 | switch value.(type) { 122 | case bool: 123 | if typedExpected, err := strconv.ParseBool(expected); err == nil && value == typedExpected { 124 | return true 125 | } 126 | case float64: 127 | if typedExpected, err := strconv.ParseFloat(expected, 64); err == nil && value == typedExpected { 128 | return true 129 | } 130 | } 131 | 132 | return false 133 | } 134 | 135 | // Set updates the value at the specified path. 136 | func (m *Map) Set(path string, rawValue any) Value { 137 | key, remainingPath, _ := strings.Cut(path, ".") 138 | if remainingPath == "" { 139 | m.setLeaf(key, rawValue) 140 | return m 141 | } 142 | if _, ok := m.m[key]; !ok { 143 | // Insert a branch 144 | m.m[key] = createRawChild(remainingPath) 145 | } 146 | 147 | m.m[key] = mustToPathValue(m.m[key]).Set(remainingPath, rawValue).Value() 148 | return m 149 | } 150 | 151 | // Delete removes the value at the specified path. 152 | func (m *Map) Delete(path string) Value { 153 | return m.Set(path, deleteValue) 154 | } 155 | 156 | func createRawChild(remainingPath string) any { 157 | nextKey, _, _ := strings.Cut(remainingPath, ".") 158 | childIsSlice := false 159 | if _, err := strconv.Atoi(nextKey); err == nil { 160 | childIsSlice = true 161 | } 162 | if childIsSlice { 163 | return []any{} 164 | } 165 | return make(map[string]any) 166 | } 167 | 168 | func (m *Map) setLeaf(key string, rawValue any) { 169 | if rawValue == deleteValue { 170 | delete(m.m, key) 171 | return 172 | } 173 | m.m[key] = toJSONValue(rawValue) 174 | } 175 | 176 | func toJSONValue(rawValue any) any { 177 | if intValue, ok := rawValue.(int); ok { 178 | // We use ints and float64 in an indistinctive way across our codebase 179 | // Only float64 is valid json numbers 180 | return float64(intValue) 181 | } 182 | return rawValue 183 | } 184 | 185 | // Array returns an empty slice for maps. 186 | func (m *Map) Array() Slice { 187 | return Slice{} 188 | } 189 | 190 | // Map returns the underlying map. 191 | func (m *Map) Map() map[string]Value { 192 | newMap := make(map[string]Value, len(m.m)) 193 | for k, v := range m.m { 194 | newMap[k] = mustToPathValue(v) 195 | } 196 | 197 | return newMap 198 | } 199 | -------------------------------------------------------------------------------- /marshalling.go: -------------------------------------------------------------------------------- 1 | package jsonnav 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // UnmarshalMap parses the json and returns the map. 9 | func UnmarshalMap(v string) (*Map, error) { 10 | result := Map{} 11 | if err := json.Unmarshal([]byte(v), &result.m); err != nil { 12 | return nil, err 13 | } 14 | return &result, nil 15 | } 16 | 17 | // Unmarshal parses the json and returns the Value. 18 | func Unmarshal(jsonString string) (Value, error) { 19 | var value any 20 | if err := json.Unmarshal([]byte(jsonString), &value); err != nil { 21 | return nil, err 22 | } 23 | 24 | return toPathValue(value) 25 | } 26 | 27 | // MarshalMap returns the json string for the provided map. 28 | func MarshalMap(m *Map) (string, error) { 29 | blob, err := json.Marshal(m.m) 30 | if err != nil { 31 | return "", err 32 | } 33 | 34 | return string(blob), nil 35 | } 36 | 37 | // Marshal returns the json string for the provided Value. 38 | func Marshal(value Value) (string, error) { 39 | blob, err := json.Marshal(value.Value()) 40 | if err != nil { 41 | return "", err 42 | } 43 | 44 | return string(blob), nil 45 | } 46 | 47 | // MustUnmarshalMap is a non-fallible version of UnmarshalMap() used for static variables and tests. 48 | func MustUnmarshalMap(v string) *Map { 49 | return must(UnmarshalMap(v)) 50 | } 51 | 52 | // FromJSONMap creates a new Map from a map[string]any. 53 | // It expects that the internal map is already a valid json map (composed only by arrays, maps and scalars). 54 | // 55 | // Note that the provided map is not copied, so any modification in the original map will reflect in the Map. 56 | func FromJSONMap(m map[string]any) *Map { 57 | return &Map{m: m} 58 | } 59 | 60 | // JSONValue is the type constraint for a json value. 61 | type JSONValue interface { 62 | float64 | string | bool | map[string]any | []any 63 | } 64 | 65 | // From creates a new Value from a JSONValue. 66 | // 67 | // It panics if the value is not float64, string, bool, map or array. 68 | // In the case of maps and slices it expects the child values to be composed only by valid json values 69 | // (float64, string, bool, map and slice). Note that the provided value is not copied, so any modification in the 70 | // original map/slice will reflect in the Value. 71 | func From[T JSONValue](value T) Value { 72 | return mustToPathValue(value) 73 | } 74 | 75 | // FromAny creates a new Value from an any value. 76 | // 77 | // In the case of maps and slices it expects the child values to be composed only by valid json values 78 | // (float64, string, bool, map and slice). Note that the provided value is not copied, so any modification in the 79 | // original map/slice will reflect in the Value. 80 | func FromAny(value any) Value { 81 | return mustToPathValue(value) 82 | } 83 | 84 | func must[T any](value T, err error) T { 85 | if err != nil { 86 | panic(err) 87 | } 88 | return value 89 | } 90 | 91 | // MustUnmarshalScalar parses the json and returns the scalar value. 92 | func MustUnmarshalScalar(v string) Value { 93 | var result any 94 | if err := json.Unmarshal([]byte(v), &result); err != nil { 95 | panic(err) 96 | } 97 | 98 | if result == nil { 99 | return &scalar{v: nil} 100 | } 101 | 102 | switch result.(type) { 103 | case string: 104 | return &scalar{v: result} 105 | case bool: 106 | return &scalar{v: result} 107 | case float64: 108 | return &scalar{v: result} 109 | } 110 | 111 | panic("invalid scalar") 112 | } 113 | 114 | // mustToPathValue returns a path value from a given json type (float64, string, bool, nil, map and array). 115 | func mustToPathValue(jsonValue any) Value { 116 | return must(toPathValue(jsonValue)) 117 | } 118 | 119 | func toPathValue(jsonValue any) (Value, error) { 120 | if jsonValue == nil { 121 | return &scalar{v: nil}, nil 122 | } 123 | 124 | switch v := jsonValue.(type) { 125 | case float64: 126 | return &scalar{v: v}, nil 127 | case string: 128 | return &scalar{v: v}, nil 129 | case bool: 130 | return &scalar{v: v}, nil 131 | case map[string]any: 132 | return &Map{m: v}, nil 133 | case []any: 134 | newSlice := make(Slice, 0, len(v)) 135 | for _, item := range v { 136 | newSlice = append(newSlice, mustToPathValue(item)) 137 | } 138 | return newSlice, nil 139 | default: 140 | // A developer issue 141 | return nil, fmt.Errorf("type %T not supported, only values from json decoding are supported", jsonValue) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /marshalling_test.go: -------------------------------------------------------------------------------- 1 | package jsonnav 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestUnmarshalMap(t *testing.T) { 10 | t.Run("should return a valid map", func(t *testing.T) { 11 | result, err := UnmarshalMap(`{"string": "value", "float64": 123, "bool": true}`) 12 | require.NoError(t, err) 13 | require.Equal(t, result.m, map[string]any{ 14 | "string": "value", 15 | "float64": 123.0, 16 | "bool": true, 17 | }) 18 | require.True(t, result.Exists()) 19 | require.False(t, result.IsArray()) 20 | require.False(t, result.Bool()) 21 | }) 22 | 23 | t.Run("should fail when it's not a valid map", func(t *testing.T) { 24 | _, err := UnmarshalMap(`true`) 25 | require.ErrorContains(t, err, "cannot unmarshal bool into Go value of type") 26 | }) 27 | } 28 | 29 | func TestUnmarshal(t *testing.T) { 30 | t.Run("should return a valid value from slice", func(t *testing.T) { 31 | result, err := Unmarshal(`["a", "b"]`) 32 | require.NoError(t, err) 33 | require.Equal(t, result.Value(), []any{"a", "b"}) 34 | require.True(t, result.Exists()) 35 | require.True(t, result.IsArray()) 36 | require.False(t, result.Bool() || result.IsObject() || result.IsString() || result.IsFloat()) 37 | }) 38 | 39 | t.Run("should return a valid value from map", func(t *testing.T) { 40 | result, err := Unmarshal(`{"string": "value", "float64": 123, "bool": true}`) 41 | require.NoError(t, err) 42 | require.Equal(t, result.Value(), map[string]any{ 43 | "string": "value", 44 | "float64": 123.0, 45 | "bool": true, 46 | }) 47 | require.True(t, result.Exists()) 48 | require.False(t, result.IsArray()) 49 | require.False(t, result.Bool() || result.IsString() || result.IsFloat()) 50 | }) 51 | 52 | t.Run("should return a valid value from scalar", func(t *testing.T) { 53 | result, err := Unmarshal(`"value"`) 54 | require.NoError(t, err) 55 | require.Equal(t, result.Value(), "value") 56 | require.True(t, result.Exists()) 57 | require.True(t, result.IsString()) 58 | require.False(t, result.IsArray() || result.IsObject() || result.Bool() || result.IsFloat()) 59 | }) 60 | 61 | t.Run("should return a valid value from null", func(t *testing.T) { 62 | result, err := Unmarshal(`null`) 63 | require.NoError(t, err) 64 | require.True(t, result.IsNull()) 65 | require.True(t, result.Exists()) 66 | require.IsType(t, &scalar{}, result) 67 | }) 68 | } 69 | 70 | func TestFrom(t *testing.T) { 71 | t.Run("should return a valid map", func(t *testing.T) { 72 | v := map[string]any{ 73 | "string": "value", 74 | "float64": 123.0, 75 | "bool": true, 76 | } 77 | result := From(v) 78 | require.True(t, result.IsObject()) 79 | require.True(t, result.Exists()) 80 | require.False(t, result.IsArray()) 81 | require.False(t, result.Bool()) 82 | require.Equal(t, result.Value(), v) 83 | require.Equal(t, result.Get("string"), &scalar{"value"}) 84 | require.Equal(t, result.Get("float64"), &scalar{123.0}) 85 | require.Equal(t, result.Get("bool"), &scalar{true}) 86 | }) 87 | 88 | t.Run("should return a valid slice", func(t *testing.T) { 89 | v := []any{"a", "b"} 90 | result := From(v) 91 | require.True(t, result.IsArray()) 92 | require.True(t, result.Exists()) 93 | require.False(t, result.IsObject()) 94 | require.False(t, result.Bool()) 95 | require.Equal(t, result.Value(), v) 96 | require.Equal(t, result.Array(), Slice{From("a"), From("b")}) 97 | }) 98 | 99 | t.Run("should return a valid scalar", func(t *testing.T) { 100 | for _, value := range []any{true, false, 123.0, "value", ""} { 101 | switch v := value.(type) { 102 | case bool: 103 | require.Equal(t, From(v), &scalar{v}) 104 | case float64: 105 | require.Equal(t, From(v), &scalar{v}) 106 | case string: 107 | require.Equal(t, From(v), &scalar{v}) 108 | } 109 | } 110 | }) 111 | } 112 | -------------------------------------------------------------------------------- /scalar.go: -------------------------------------------------------------------------------- 1 | package jsonnav 2 | 3 | import ( 4 | "strconv" 5 | ) 6 | 7 | // scalar represents either a boolean, float64 or string type. Inner value can be nil. 8 | type scalar struct { 9 | v any 10 | } 11 | 12 | // Represents an undefined JSON value. 13 | var undefinedScalar Value = &scalar{v: nil} 14 | 15 | func (s *scalar) Exists() bool { 16 | return s != undefinedScalar 17 | } 18 | 19 | func (s *scalar) IsArray() bool { 20 | return false 21 | } 22 | 23 | func (*scalar) IsObject() bool { 24 | return false 25 | } 26 | 27 | func (s *scalar) IsString() bool { 28 | if s.v == nil { 29 | return false 30 | } 31 | if _, ok := s.v.(string); ok { 32 | return true 33 | } 34 | return false 35 | } 36 | 37 | func (s *scalar) IsFloat() bool { 38 | if s.v == nil { 39 | return false 40 | } 41 | if _, ok := s.v.(float64); ok { 42 | return true 43 | } 44 | return false 45 | } 46 | 47 | func (s *scalar) IsBool() bool { 48 | if s.v == nil { 49 | return false 50 | } 51 | if _, ok := s.v.(bool); ok { 52 | return true 53 | } 54 | return false 55 | } 56 | 57 | func (s *scalar) IsEmpty() bool { 58 | return s.v == nil || s.v == "" 59 | } 60 | 61 | func (s *scalar) IsNull() bool { 62 | return s.v == nil 63 | } 64 | 65 | func (s *scalar) Bool() bool { 66 | return s.v == true 67 | } 68 | 69 | func (s *scalar) Float() float64 { 70 | if s.v == nil { 71 | return 0 72 | } 73 | switch v := s.v.(type) { 74 | case float64: 75 | return v 76 | case string: 77 | n, _ := strconv.ParseFloat(v, 64) 78 | return n 79 | default: 80 | return 0 81 | } 82 | } 83 | 84 | func (s *scalar) Int() int64 { 85 | if s.v == nil { 86 | return 0 87 | } 88 | switch v := s.v.(type) { 89 | case float64: 90 | return int64(v) 91 | case string: 92 | n, _ := strconv.ParseInt(v, 10, 64) 93 | return n 94 | default: 95 | return 0 96 | } 97 | } 98 | 99 | func (s *scalar) String() string { 100 | if s.v == nil { 101 | return "" 102 | } 103 | switch v := s.v.(type) { 104 | case float64: 105 | return strconv.FormatFloat(v, 'E', -1, 64) 106 | case string: 107 | return v 108 | case bool: 109 | return strconv.FormatBool(v) 110 | default: 111 | return "" 112 | } 113 | } 114 | 115 | func (s *scalar) Value() any { 116 | return s.v 117 | } 118 | 119 | func (s *scalar) Get(path string) Value { 120 | if path[0] == '=' { 121 | // Equality check 122 | if path[1] == '"' { // string literal 123 | stringValue := path[2 : len(path)-1] // remove quotes 124 | if s.v == stringValue { 125 | return s 126 | } 127 | } 128 | } 129 | 130 | // No nested values 131 | return undefinedScalar 132 | } 133 | 134 | func (s *scalar) Set(_ string, _ any) Value { 135 | // Scalar values can't be set by path: noop 136 | return s 137 | } 138 | 139 | func (s *scalar) Delete(_ string) Value { 140 | // Scalar values can't be deleted by path: noop 141 | return s 142 | } 143 | 144 | func (s *scalar) Array() Slice { 145 | if s.v == nil { 146 | return []Value{} 147 | } 148 | return []Value{s} 149 | } 150 | 151 | func (s *scalar) Map() map[string]Value { 152 | return map[string]Value{} 153 | } 154 | -------------------------------------------------------------------------------- /slice.go: -------------------------------------------------------------------------------- 1 | package jsonnav 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | // Slice represents an array of values. 9 | type Slice []Value 10 | 11 | var _ Value = Slice{} 12 | 13 | // Exists returns true for slices. 14 | func (s Slice) Exists() bool { 15 | return true 16 | } 17 | 18 | // IsEmpty returns true if the slice does not have any items. 19 | func (s Slice) IsEmpty() bool { 20 | return len(s) == 0 21 | } 22 | 23 | // IsNull returns false for slices. 24 | func (s Slice) IsNull() bool { 25 | return false 26 | } 27 | 28 | // IsArray returns true for slices. 29 | func (s Slice) IsArray() bool { 30 | return true 31 | } 32 | 33 | // IsObject returns false for slices. 34 | func (s Slice) IsObject() bool { 35 | return false 36 | } 37 | 38 | // IsString returns false for slices. 39 | func (s Slice) IsString() bool { 40 | return false 41 | } 42 | 43 | // IsFloat returns false for slices. 44 | func (s Slice) IsFloat() bool { 45 | return false 46 | } 47 | 48 | // IsBool returns false for slices. 49 | func (s Slice) IsBool() bool { 50 | return false 51 | } 52 | 53 | // Bool returns false for slices. 54 | func (s Slice) Bool() bool { 55 | return false 56 | } 57 | 58 | // Float returns 0 for slices. 59 | func (s Slice) Float() float64 { 60 | return 0 61 | } 62 | 63 | // Int returns 0 for slices. 64 | func (s Slice) Int() int64 { 65 | return 0 66 | } 67 | 68 | // String returns an empty string for slices. 69 | func (s Slice) String() string { 70 | return "" 71 | } 72 | 73 | // At returns the value at the specified index. 74 | // If the index is out of range, it returns an scalar with an internal nil value. 75 | func (s Slice) At(index int) Value { 76 | if index < 0 { 77 | panic("index out of range") 78 | } 79 | 80 | if index >= len(s) { 81 | return undefinedScalar 82 | } 83 | return s[index] 84 | } 85 | 86 | // Value returns the inner values as a slice. 87 | func (s Slice) Value() any { 88 | // []any, for JSON arrays 89 | values := make([]any, 0, len(s)) 90 | for _, v := range s { 91 | values = append(values, v.Value()) 92 | } 93 | 94 | return values 95 | } 96 | 97 | // Get searches for the specified path within the slice. 98 | func (s Slice) Get(path string) Value { 99 | if path == "#" { 100 | return &scalar{v: len(s)} 101 | } 102 | if strings.HasPrefix(path, "#(") { 103 | // Apply the condition 104 | key, remainingPath, _ := strings.Cut(path, ".") 105 | childPath := key[2:] 106 | shouldReturnList := false 107 | if strings.HasSuffix(childPath, ")#") { 108 | childPath = childPath[:len(childPath)-2] // remove trailing ")#" chars 109 | shouldReturnList = true 110 | } else { 111 | childPath = childPath[:len(childPath)-1] // remove the parenthesis 112 | } 113 | 114 | slice := s.applyChildConditionPath(childPath) 115 | var result Value = slice 116 | 117 | if !shouldReturnList { 118 | // Return the first result 119 | if len(slice) == 0 { 120 | result = undefinedScalar 121 | } else { 122 | result = slice[0] 123 | } 124 | } 125 | 126 | if remainingPath != "" { 127 | result = result.Get(remainingPath) 128 | } 129 | 130 | return result 131 | } 132 | if strings.HasPrefix(path, "#.") { 133 | // Apply the selection to the slice 134 | childPath := path[2:] 135 | newSlice := make(Slice, 0, len(s)) 136 | for _, value := range s { 137 | v := value.Get(childPath) 138 | if v.Exists() { 139 | newSlice = append(newSlice, v) 140 | } 141 | } 142 | return newSlice 143 | } 144 | 145 | indexString, remainingPath, _ := strings.Cut(path, ".") 146 | index, err := strconv.Atoi(indexString) 147 | if err == nil && index >= 0 && index < len(s) { 148 | result := s[index] 149 | if remainingPath != "" { 150 | return result.Get(remainingPath) 151 | } 152 | return result 153 | } 154 | 155 | // path is not supported on a slice 156 | return undefinedScalar 157 | } 158 | 159 | func (s Slice) applyChildConditionPath(childPath string) Slice { 160 | newSlice := make(Slice, 0, len(s)) 161 | for _, value := range s { 162 | conditionValue := value.Get(childPath) 163 | if conditionValue.Exists() { 164 | // Return the complete child value if the condition matches 165 | newSlice = append(newSlice, value) 166 | } 167 | } 168 | return newSlice 169 | } 170 | 171 | // Set sets the value at the specified path. 172 | func (s Slice) Set(path string, rawValue any) Value { 173 | key, remainingPath, _ := strings.Cut(path, ".") 174 | 175 | // Apply the rawValue to all slice elements 176 | if key == "#" { 177 | result := s 178 | for index, element := range result { 179 | result[index] = element.Set(remainingPath, rawValue) 180 | } 181 | return result 182 | } 183 | 184 | index, err := strconv.Atoi(key) 185 | if err != nil { 186 | // expected an index for a PathValueSlice: noop 187 | return s 188 | } 189 | result := s 190 | if remainingPath == "" { 191 | if rawValue == deleteValue { 192 | // Remove by index 193 | if index < len(result) { 194 | result = append(result[:index], result[index+1:]...) 195 | } 196 | return result 197 | } 198 | result = growSliceIfNeeded(result, index) 199 | // Edit in place 200 | result[index] = mustToPathValue(toJSONValue(rawValue)) 201 | return result 202 | } 203 | 204 | // Set in subpath 205 | result = growSliceIfNeeded(result, index) 206 | child := result[index] 207 | if child.IsNull() { 208 | child = mustToPathValue(createRawChild(remainingPath)) 209 | } 210 | result[index] = child.Set(remainingPath, rawValue) 211 | 212 | return result 213 | } 214 | 215 | // Delete removes the value at the specified path. 216 | func (s Slice) Delete(path string) Value { 217 | return s.Set(path, deleteValue) 218 | } 219 | 220 | func growSliceIfNeeded(slice Slice, index int) Slice { 221 | initialLen := len(slice) 222 | if initialLen <= index { 223 | for i := 0; i < index+1-initialLen; i++ { 224 | slice = append(slice, &scalar{v: nil}) 225 | } 226 | } 227 | 228 | return slice 229 | } 230 | 231 | // Array returns the same instance. 232 | func (s Slice) Array() Slice { 233 | return s 234 | } 235 | 236 | // Map returns an empty map for slices. 237 | func (s Slice) Map() map[string]Value { 238 | return map[string]Value{} 239 | } 240 | -------------------------------------------------------------------------------- /value.go: -------------------------------------------------------------------------------- 1 | package jsonnav 2 | 3 | // Value represents the result of a path search expression over a json element. 4 | // For example: `value.Get("a.b")` will access the value in the object at the path "a.b". 5 | // 6 | // Get() and `Set()` methods partially supports GJSON syntax: https://github.com/tidwall/gjson/blob/master/SYNTAX.md 7 | // 8 | // Not supported expressions: 9 | // - Nested conditions like "friends.#(nets.#(=="fb"))#". 10 | // - Tilde comparison. 11 | type Value interface { 12 | // Exists returns true if value exists. 13 | Exists() bool 14 | 15 | // IsEmpty returns true if the value is 16 | // - a null JSON value 17 | // - an object containing no items 18 | // - an array of zero length 19 | // - an empty string 20 | IsEmpty() bool 21 | 22 | // IsNull returns true when the value is representation of the JSON null value. 23 | IsNull() bool 24 | 25 | // IsArray returns true if the value is a JSON array. 26 | IsArray() bool 27 | 28 | // IsObject returns true if the value is a JSON object/map. 29 | IsObject() bool 30 | 31 | // IsString returns true if the value is a string scalar. 32 | IsString() bool 33 | 34 | // IsFloat returns true if the value is a float/number scalar. 35 | IsFloat() bool 36 | 37 | // IsBool returns true if the value is a bool scalar. 38 | IsBool() bool 39 | 40 | // Bool returns a boolean representation. 41 | // When the value is not a boolean scalar, it returns false. 42 | Bool() bool 43 | 44 | // Float returns a float64 representation. 45 | // When the value is not a number scalar, it returns 0. 46 | Float() float64 47 | 48 | // Int returns an integer representation. 49 | // When the value is not a number scalar, it returns 0. 50 | Int() int64 51 | 52 | // String returns a string representation of the value. 53 | // If the internal value is a bool or float64 scalar, it will be converted to string. 54 | // If the internal value is a JSON object or array, it will return empty string. 55 | String() string 56 | 57 | // Value returns one of these types: 58 | // 59 | // bool, for JSON booleans 60 | // float64, for JSON numbers 61 | // Number, for JSON numbers 62 | // string, for JSON string literals 63 | // nil, for JSON null 64 | // map[string]any, for JSON objects 65 | // []any, for JSON arrays 66 | Value() any 67 | 68 | // Get searches for the specified path. 69 | Get(path string) Value 70 | 71 | // Set sets the value in the provided path and returns the modified instance. 72 | // If the path does not exist, it will be created. 73 | Set(path string, rawValue any) Value 74 | 75 | // Delete deletes the value in the provided path and returns the modified instance. 76 | Delete(path string) Value 77 | 78 | // Array returns back an array of values. 79 | // If the result represents a null value or is non-existent, then an empty 80 | // array will be returned. 81 | // If the result is not a JSON array, the return value will be an 82 | // array containing one result. 83 | Array() Slice 84 | 85 | // Map returns back a map of values. The result should be a JSON object. 86 | // If the result is not a JSON object, the return value will be an empty map. 87 | Map() map[string]Value 88 | } 89 | 90 | // An internal type to mark the set operation as a delete. 91 | type deleteType int 92 | 93 | const deleteValue deleteType = -1 94 | -------------------------------------------------------------------------------- /value_test.go: -------------------------------------------------------------------------------- 1 | package jsonnav 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | const testJSON = `{ 10 | "string": "value", 11 | "emptyString": "", 12 | "float64": 123, 13 | "bool": true, 14 | "bool2": false, 15 | "nil": null, 16 | "object": {"a": 1.1}, 17 | "array": ["a", "b"], 18 | "nestedObject": {"name": "John", "attrs": {"age": 31}}, 19 | "nestedArray": [{"name": "Alice", "attrs": {"age": 33}}, {"name": "Bob"}] 20 | }` 21 | 22 | func TestGet(t *testing.T) { 23 | value, err := UnmarshalMap(testJSON) 24 | require.NoError(t, err) 25 | 26 | t.Run("should return a simple scalar value", func(t *testing.T) { 27 | require.Equal(t, "value", value.Get("string").String()) 28 | require.Equal(t, 123.0, value.Get("float64").Float()) 29 | require.Equal(t, int64(123), value.Get("float64").Int()) 30 | require.Equal(t, true, value.Get("bool").Bool()) 31 | require.Equal(t, false, value.Get("bool2").Bool()) 32 | require.Equal(t, &scalar{v: nil}, value.Get("nil")) 33 | require.True(t, value.Get("nil").Exists()) 34 | require.False(t, value.Get("NOT_EXISTS").Exists()) 35 | require.Equal(t, &Map{m: map[string]any{"a": 1.1}}, value.Get("object")) 36 | }) 37 | 38 | t.Run("should support slices", func(t *testing.T) { 39 | s := value.Get("array") 40 | require.True(t, s.IsArray()) 41 | require.False(t, s.IsObject()) 42 | require.False(t, s.IsString()) 43 | require.False(t, s.IsFloat()) 44 | require.False(t, s.IsBool()) 45 | require.Equal(t, Slice{&scalar{v: "a"}, &scalar{v: "b"}}, s.Array()) 46 | require.Equal(t, "a", s.Array().At(0).String()) 47 | require.Equal(t, &scalar{"b"}, s.Array().At(1)) 48 | require.Equal(t, undefinedScalar, s.Array().At(2)) 49 | }) 50 | 51 | t.Run("should return nested values", func(t *testing.T) { 52 | require.False(t, value.Get("string.z").Exists()) 53 | require.Equal(t, "John", value.Get("nestedObject.name").String()) 54 | require.Equal(t, int64(31), value.Get("nestedObject.attrs.age").Int()) 55 | require.True(t, value.Get("nestedArray.#.name").IsArray()) 56 | require.Equal(t, 57 | Slice{&scalar{v: "Alice"}, &scalar{v: "Bob"}}, 58 | value.Get("nestedArray.#.name")) 59 | }) 60 | 61 | t.Run("should support conditions", func(t *testing.T) { 62 | require.Equal(t, "value", value.Get("string").Get(`="value"`).String()) 63 | require.False(t, value.Get("string").Get(`="zzz"`).Exists()) 64 | require.Equal(t, value, value.Get("string=value")) 65 | require.True(t, value.Get("nestedArray.#(name=Bob)").IsObject()) 66 | require.Equal(t, "Bob", value.Get("nestedArray.#(name=Bob)").Get("name").String()) 67 | require.True(t, value.Get("nestedArray.#(name=Bob)#").IsArray()) 68 | require.Equal(t, 1, len(value.Get("nestedArray.#(name=Bob)#").Array())) 69 | require.Equal(t, "Bob", value.Get("nestedArray.#(name=Bob)#").Array()[0].Get("name").String()) 70 | require.Equal(t, 1, len(value.Get("nestedArray.#(attrs)#").Array())) 71 | require.Equal(t, "Alice", value.Get("nestedArray.#(attrs)#").Array()[0].Get("name").String()) 72 | require.Equal( 73 | t, 74 | Slice{&scalar{v: 33.0}}, 75 | value.Get("nestedArray.#(name=Alice)#").Get("#.attrs.age")) 76 | require.Equal(t, &scalar{v: 33.0}, value.Get("nestedArray.#(name=Alice).attrs.age")) 77 | require.Equal(t, &scalar{v: "a"}, value.Get(`array.#(="a")`)) 78 | }) 79 | 80 | t.Run("should get array values using indices", func(t *testing.T) { 81 | require.Equal(t, "a", value.Get("array.0").String()) 82 | require.Equal(t, "b", value.Get("array.1").String()) 83 | require.False(t, value.Get("array.2").Exists()) 84 | require.Equal(t, "", value.Get("array.2").String()) 85 | require.False(t, value.Get("array.-1").Exists()) 86 | require.False(t, value.Get("array.zzzz").Exists()) // not supported by slice 87 | require.Equal(t, "Alice", value.Get("nestedArray.0.name").String()) 88 | require.Equal(t, "Bob", value.Get("nestedArray.1.name").String()) 89 | require.False(t, value.Get("nestedArray.2.name").Exists()) 90 | }) 91 | 92 | t.Run("should support nested get calls", func(t *testing.T) { 93 | require.Equal(t, "John", value.Get("nestedObject").Get("name").String()) 94 | require.Equal(t, "Alice", value.Get("nestedArray").Get("#(name=Alice)").Get("name").String()) 95 | require.Equal(t, Slice{&scalar{"Alice"}, &scalar{"Bob"}}, value.Get("nestedArray").Get("#.name")) 96 | 97 | t.Run("with non-existing values", func(t *testing.T) { 98 | require.True(t, value.Get("nestedArray").Get("#(name=Alice)").Get("NOT_EXISTS").Array().IsEmpty()) 99 | require.False(t, value.Get("nestedArray").Get("#(name=ZZZZ)").Exists()) 100 | require.False(t, value.Get("a").Get("b").Get("c").Exists()) 101 | }) 102 | }) 103 | } 104 | 105 | func TestSet(t *testing.T) { 106 | t.Run("should set leaf values", func(t *testing.T) { 107 | value := MustUnmarshalMap(testJSON) 108 | value.Set("new", "new value") 109 | value.Set("string", "value2") 110 | require.Equal(t, "new value", value.Get("new").String()) 111 | require.Equal(t, "value2", value.Get("string").String()) 112 | require.Equal(t, 123.0, value.Get("float64").Float()) 113 | require.Equal(t, int64(123), value.Get("float64").Int()) 114 | require.Equal(t, true, value.Get("bool").Bool()) 115 | }) 116 | 117 | t.Run("should set branch values", func(t *testing.T) { 118 | value := MustUnmarshalMap(testJSON) 119 | require.Equal(t, 1.1, value.Get("object.a").Float()) 120 | value.Set("object.a", 2.0) 121 | value.Set("object.b", 3.1) 122 | value.Set("object.c.prop1.nested", 4.0) 123 | value.Set("birth.date", "1942-11-27") 124 | require.Equal(t, 2.0, value.Get("object.a").Float()) 125 | require.Equal(t, 3.1, value.Get("object.b").Float()) 126 | require.True(t, value.Get("object.c").Exists()) 127 | require.True(t, value.Get("object.c.prop1").Exists()) 128 | require.Equal(t, 4.0, value.Get("object.c.prop1.nested").Float()) 129 | require.Equal(t, "value", value.Get("string").String()) 130 | require.Equal(t, "1942-11-27", value.Get("birth").Get("date").String()) 131 | }) 132 | 133 | t.Run("should set using indices", func(t *testing.T) { 134 | value := MustUnmarshalMap(testJSON) 135 | value.Set("array.1", "c") 136 | require.Equal(t, []any{"a", "c"}, value.Get("array").Value()) 137 | 138 | value.Set("nestedArray.1.lastName", "Smith") 139 | require.Equal( 140 | t, 141 | map[string]any{"name": "Bob", "lastName": "Smith"}, 142 | value.Get("nestedArray.#(name=Bob)").Value()) 143 | }) 144 | 145 | t.Run("should set all elements of an array when using #", func(t *testing.T) { 146 | value := MustUnmarshalMap(testJSON) 147 | 148 | value.Set("nestedArray.#.name", "Chuck Norris") 149 | require.Equal( 150 | t, 151 | []any{"Chuck Norris", "Chuck Norris"}, 152 | value.Get("nestedArray.#.name").Value()) 153 | 154 | // nested path 155 | newAge := float64(42) 156 | value.Set("nestedArray.#.attrs.age", newAge) 157 | require.Equal( 158 | t, 159 | []any{newAge, newAge}, 160 | value.Get("nestedArray.#.attrs.age").Value()) 161 | 162 | // delete value 163 | value.Set("nestedArray.#.attrs", deleteValue) 164 | require.Equal( 165 | t, 166 | []any{map[string]any{"name": "Chuck Norris"}, map[string]any{"name": "Chuck Norris"}}, 167 | value.Get("nestedArray").Value()) 168 | }) 169 | 170 | t.Run("should set nested elements in a map", func(t *testing.T) { 171 | value := MustUnmarshalMap(testJSON) 172 | value.Get("object").Set("b", 2.0) 173 | require.Subset(t, value.Value(), map[string]any{ 174 | "object": map[string]any{"a": 1.1, "b": 2.0}, 175 | }) 176 | }) 177 | } 178 | 179 | func TestDelete(t *testing.T) { 180 | t.Run("should delete values", func(t *testing.T) { 181 | value := MustUnmarshalMap(testJSON) 182 | value.Delete("new") 183 | value.Delete("string") 184 | value.Delete("array.0") 185 | value.Delete("nestedArray.0.attrs") 186 | require.False(t, value.Get("new").Exists()) 187 | require.False(t, value.Get("string").Exists()) 188 | require.Equal(t, true, value.Get("bool").Bool()) 189 | require.Equal(t, []any{"b"}, value.Get("array").Value()) 190 | require.Equal(t, map[string]any{"name": "Alice"}, value.Get("nestedArray.#(name=Alice)").Value()) 191 | 192 | // Delete all nested elements in an array 193 | value = MustUnmarshalMap(testJSON) 194 | value.Delete("nestedArray.#.name") 195 | require.Equal(t, 196 | []any{ 197 | map[string]any{"attrs": map[string]any{"age": float64(33)}}, 198 | map[string]any{}, 199 | }, 200 | value.Get("nestedArray").Value()) 201 | }) 202 | } 203 | --------------------------------------------------------------------------------