Skip to content

Commit 9256a75

Browse files
committed
allow optionality for key=val tokens
1 parent 8542be6 commit 9256a75

File tree

9 files changed

+183
-39
lines changed

9 files changed

+183
-39
lines changed

docs/examples.md

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
## Pointer syntax
22

33
- pointers always start at the root of the document
4+
45
- strings typically refer to hash keys (ex: `/key1`)
56
- strings ending with `?` refer to hash keys that may or may not exist
7+
- "optionality" carries over to the items to the right
8+
69
- integers refer to array indices (ex: `/0`, `/-1`)
10+
711
- `-` refers to an imaginary index after last array index (ex: `/-`)
8-
- `key=val` notation matches hashes (ex: `/key=val`)
12+
13+
- `key=val` notation matches hashes within an array (ex: `/key=val`)
14+
- values ending with `?` refer to array items that may or may not exist
915

1016
See pointer test examples in [patch/pointer_test.go](../patch/pointer_test.go).
1117

1218
## Operations
1319

1420
Following example is used to demonstrate operations below:
1521

16-
```
22+
```yaml
1723
key: 1
1824

1925
key2:
@@ -74,7 +80,17 @@ There are two available operations: `replace` and `remove`.
7480

7581
- requires that `key2` hash exists
7682
- allows `nested`, `another_nested` and `super_nested` not to exist because `?` carries over to nested keys
77-
- creates `another_nested` and `super_nested` before sets `super_nested` to `10`
83+
- creates `another_nested` and `super_nested` before setting `super_nested` to `10`, resulting in:
84+
85+
```yaml
86+
...
87+
key2:
88+
nested:
89+
another_nested:
90+
super_nested: 10
91+
super_nested: 2
92+
other: 3
93+
```
7894

7995
### Array
8096

@@ -116,7 +132,7 @@ There are two available operations: `replace` and `remove`.
116132
- finds array item with matching key `name` with value `item7`
117133
- adds `count` key as a sibling of name, resulting in:
118134

119-
```
135+
```yaml
120136
...
121137
items:
122138
- name: item7
@@ -132,4 +148,23 @@ There are two available operations: `replace` and `remove`.
132148

133149
- errors because there are two values that have `item8` as their `name`
134150

151+
```yaml
152+
- type: replace
153+
path: /items/name=item9?/count
154+
value: 10
155+
```
156+
157+
- appends array item with matching key `name` with value `item9` because values ends with `?` and item does not exist
158+
- creates `count` and sets it to `10` within created array item, resulting in:
159+
160+
```yaml
161+
...
162+
items:
163+
- name: item7
164+
- name: item8
165+
- name: item8
166+
- name: item9
167+
count: 10
168+
```
169+
135170
See full example in [patch/integration_test.go](../patch/integration_test.go).

patch/ops.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ type Op interface {
77
}
88

99
// Ensure basic operations implement Op
10+
var _ Op = Ops{}
1011
var _ Op = ReplaceOp{}
1112
var _ Op = RemoveOp{}
1213
var _ Op = ErrOp{}

patch/pointer.go

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,17 +46,13 @@ func NewPointerFromString(str string) (Pointer, error) {
4646

4747
tok = rfc6901Decoder.Replace(tok)
4848

49+
// parse as after last index
4950
if isLast && tok == "-" {
5051
tokens = append(tokens, AfterLastIndexToken{})
5152
continue
5253
}
5354

54-
kv := strings.SplitN(tok, "=", 2)
55-
if len(kv) == 2 {
56-
tokens = append(tokens, MatchingIndexToken{Key: kv[0], Value: kv[1]})
57-
continue
58-
}
59-
55+
// parse as index
6056
idx, err := strconv.Atoi(tok)
6157
if err == nil {
6258
tokens = append(tokens, IndexToken{idx})
@@ -67,6 +63,20 @@ func NewPointerFromString(str string) (Pointer, error) {
6763
optional = true
6864
}
6965

66+
// parse name=val
67+
kv := strings.SplitN(tok, "=", 2)
68+
if len(kv) == 2 {
69+
token := MatchingIndexToken{
70+
Key: kv[0],
71+
Value: strings.TrimSuffix(kv[1], "?"),
72+
Optional: optional,
73+
}
74+
75+
tokens = append(tokens, token)
76+
continue
77+
}
78+
79+
// it's a map key
7080
token := KeyToken{
7181
Key: strings.TrimSuffix(tok, "?"),
7282
Optional: optional,
@@ -110,16 +120,28 @@ func (p Pointer) String() string {
110120
strs = append(strs, "-")
111121

112122
case MatchingIndexToken:
113-
strs = append(strs, fmt.Sprintf("%s=%s", typedToken.Key, typedToken.Value))
123+
key := rfc6901Encoder.Replace(typedToken.Key)
124+
val := rfc6901Encoder.Replace(typedToken.Value)
125+
126+
if typedToken.Optional {
127+
if !optional {
128+
val += "?"
129+
optional = true
130+
}
131+
}
132+
133+
strs = append(strs, fmt.Sprintf("%s=%s", key, val))
114134

115135
case KeyToken:
116136
str := rfc6901Encoder.Replace(typedToken.Key)
137+
117138
if typedToken.Optional { // /key?/key2/key3
118139
if !optional {
119140
str += "?"
120141
optional = true
121142
}
122143
}
144+
123145
strs = append(strs, str)
124146

125147
default:

patch/pointer_test.go

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,6 @@ var testCases = []PointerTestCase{
2121
{"/", []Token{RootToken{}, KeyToken{Key: ""}}},
2222
{"/ ", []Token{RootToken{}, KeyToken{Key: " "}}},
2323

24-
// Escaping (todo support ~2 for '?'; ~3 for '=')
25-
{"/m~0n", []Token{RootToken{}, KeyToken{Key: "m~n"}}},
26-
{"/a~01b", []Token{RootToken{}, KeyToken{Key: "a~1b"}}},
27-
{"/a~1b", []Token{RootToken{}, KeyToken{Key: "a/b"}}},
28-
29-
// Special chars
30-
{"/c%d", []Token{RootToken{}, KeyToken{Key: "c%d"}}},
31-
{"/e^f", []Token{RootToken{}, KeyToken{Key: "e^f"}}},
32-
{"/g|h", []Token{RootToken{}, KeyToken{Key: "g|h"}}},
33-
{"/i\\j", []Token{RootToken{}, KeyToken{Key: "i\\j"}}},
34-
{"/k\"l", []Token{RootToken{}, KeyToken{Key: "k\"l"}}},
35-
3624
// Maps
3725
{"/key", []Token{RootToken{}, KeyToken{Key: "key"}}},
3826
{"/key/", []Token{RootToken{}, KeyToken{Key: "key"}, KeyToken{Key: ""}}},
@@ -55,10 +43,42 @@ var testCases = []PointerTestCase{
5543

5644
// Matching index token
5745
{"/name=val", []Token{RootToken{}, MatchingIndexToken{Key: "name", Value: "val"}}},
46+
{"/name=val?", []Token{RootToken{}, MatchingIndexToken{Key: "name", Value: "val", Optional: true}}},
47+
{"/name=val?/name2=val", []Token{
48+
RootToken{},
49+
MatchingIndexToken{Key: "name", Value: "val", Optional: true},
50+
MatchingIndexToken{Key: "name2", Value: "val", Optional: true},
51+
}},
5852
{"/=", []Token{RootToken{}, MatchingIndexToken{Key: "", Value: ""}}},
53+
{"/=?", []Token{RootToken{}, MatchingIndexToken{Key: "", Value: "", Optional: true}}},
5954
{"/name=", []Token{RootToken{}, MatchingIndexToken{Key: "name", Value: ""}}},
6055
{"/=val", []Token{RootToken{}, MatchingIndexToken{Key: "", Value: "val"}}},
6156
{"/==", []Token{RootToken{}, MatchingIndexToken{Key: "", Value: "="}}},
57+
58+
// Optionality
59+
{"/key?/name=val", []Token{
60+
RootToken{},
61+
KeyToken{Key: "key", Optional: true},
62+
MatchingIndexToken{Key: "name", Value: "val", Optional: true},
63+
}},
64+
{"/name=val?/key", []Token{
65+
RootToken{},
66+
MatchingIndexToken{Key: "name", Value: "val", Optional: true},
67+
KeyToken{Key: "key", Optional: true},
68+
}},
69+
70+
// Escaping (todo support ~2 for '?'; ~3 for '=')
71+
{"/m~0n", []Token{RootToken{}, KeyToken{Key: "m~n"}}},
72+
{"/a~01b", []Token{RootToken{}, KeyToken{Key: "a~1b"}}},
73+
{"/a~1b", []Token{RootToken{}, KeyToken{Key: "a/b"}}},
74+
{"/name~0n=val~0n", []Token{RootToken{}, MatchingIndexToken{Key: "name~n", Value: "val~n"}}},
75+
76+
// Special chars
77+
{"/c%d", []Token{RootToken{}, KeyToken{Key: "c%d"}}},
78+
{"/e^f", []Token{RootToken{}, KeyToken{Key: "e^f"}}},
79+
{"/g|h", []Token{RootToken{}, KeyToken{Key: "g|h"}}},
80+
{"/i\\j", []Token{RootToken{}, KeyToken{Key: "i\\j"}}},
81+
{"/k\"l", []Token{RootToken{}, KeyToken{Key: "k\"l"}}},
6282
}
6383

6484
var _ = Describe("NewPointer", func() {

patch/remove_op.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ func (op RemoveOp) Apply(doc interface{}) (interface{}, error) {
6262
}
6363
}
6464

65+
if typedToken.Optional && len(idxs) == 0 {
66+
return doc, nil
67+
}
68+
6569
if len(idxs) != 1 {
6670
errMsg := "Expected to find exactly one matching array item for path '%s' but found %d"
6771
return nil, fmt.Errorf(errMsg, NewPointer(tokens[:i+2]), len(idxs))

patch/remove_op_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,25 @@ var _ = Describe("RemoveOp.Apply", func() {
155155
}))
156156
})
157157

158+
It("removes nested matching item that does not exist", func() {
159+
doc := map[interface{}]interface{}{
160+
"abc": []interface{}{
161+
map[interface{}]interface{}{"opr": "opr"},
162+
},
163+
"xyz": "xyz",
164+
}
165+
166+
res, err := RemoveOp{Path: MustNewPointerFromString("/abc/opr=not-opr?")}.Apply(doc)
167+
Expect(err).ToNot(HaveOccurred())
168+
169+
Expect(res).To(Equal(map[interface{}]interface{}{
170+
"abc": []interface{}{
171+
map[interface{}]interface{}{"opr": "opr"},
172+
},
173+
"xyz": "xyz",
174+
}))
175+
})
176+
158177
It("returns an error if it's not an array is being accessed", func() {
159178
_, err := RemoveOp{Path: MustNewPointerFromString("/key=val")}.Apply(map[interface{}]interface{}{})
160179
Expect(err).To(HaveOccurred())

patch/replace_op.go

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,18 +72,24 @@ func (op ReplaceOp) Apply(doc interface{}) (interface{}, error) {
7272
}
7373
}
7474

75-
if len(idxs) != 1 {
76-
errMsg := "Expected to find exactly one matching array item for path '%s' but found %d"
77-
return nil, fmt.Errorf(errMsg, NewPointer(tokens[:i+2]), len(idxs))
78-
}
75+
if typedToken.Optional && len(idxs) == 0 {
76+
obj = map[interface{}]interface{}{typedToken.Key: typedToken.Value}
77+
prevUpdate(append(typedObj, obj))
78+
// no need to change prevUpdate since matching item can only be a map
79+
} else {
80+
if len(idxs) != 1 {
81+
errMsg := "Expected to find exactly one matching array item for path '%s' but found %d"
82+
return nil, fmt.Errorf(errMsg, NewPointer(tokens[:i+2]), len(idxs))
83+
}
7984

80-
idx := idxs[0]
85+
idx := idxs[0]
8186

82-
if isLast {
83-
typedObj[idx] = op.Value
84-
} else {
85-
obj = typedObj[idx]
86-
// no need to change prevUpdate since matching item can only be a map
87+
if isLast {
88+
typedObj[idx] = op.Value
89+
} else {
90+
obj = typedObj[idx]
91+
// no need to change prevUpdate since matching item can only be a map
92+
}
8793
}
8894

8995
case KeyToken:
@@ -110,10 +116,12 @@ func (op ReplaceOp) Apply(doc interface{}) (interface{}, error) {
110116
switch tokens[i+2].(type) {
111117
case AfterLastIndexToken:
112118
obj = []interface{}{}
119+
case MatchingIndexToken:
120+
obj = []interface{}{}
113121
case KeyToken:
114122
obj = map[interface{}]interface{}{}
115123
default:
116-
errMsg := "Expected to find key or after last index token at path '%s'"
124+
errMsg := "Expected to find key, matching index or after last index token at path '%s'"
117125
return nil, fmt.Errorf(errMsg, NewPointer(tokens[:i+3]))
118126
}
119127

patch/replace_op_test.go

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ var _ = Describe("ReplaceOp.Apply", func() {
7575
})
7676
})
7777

78-
Describe("array wtih after last item", func() {
78+
Describe("array with after last item", func() {
7979
It("appends new item", func() {
8080
res, err := ReplaceOp{Path: MustNewPointerFromString("/-"), Value: 10}.Apply([]interface{}{})
8181
Expect(err).ToNot(HaveOccurred())
@@ -146,7 +146,7 @@ var _ = Describe("ReplaceOp.Apply", func() {
146146
}))
147147
})
148148

149-
It("returns an error if no items found", func() {
149+
It("returns an error if no items found and matching is not optional", func() {
150150
doc := []interface{}{
151151
map[interface{}]interface{}{"key": "val2"},
152152
map[interface{}]interface{}{"key2": "val"},
@@ -208,6 +208,38 @@ var _ = Describe("ReplaceOp.Apply", func() {
208208
}))
209209
})
210210

211+
It("appends missing matching item if it does not exist", func() {
212+
doc := []interface{}{map[interface{}]interface{}{"xyz": "xyz"}}
213+
214+
res, err := ReplaceOp{Path: MustNewPointerFromString("/name=val?/efg"), Value: 1}.Apply(doc)
215+
Expect(err).ToNot(HaveOccurred())
216+
217+
Expect(res).To(Equal([]interface{}{
218+
map[interface{}]interface{}{"xyz": "xyz"},
219+
map[interface{}]interface{}{
220+
"name": "val",
221+
"efg": 1,
222+
},
223+
}))
224+
})
225+
226+
It("appends nested missing matching item if it does not exist", func() {
227+
doc := []interface{}{map[interface{}]interface{}{"xyz": "xyz"}}
228+
229+
res, err := ReplaceOp{Path: MustNewPointerFromString("/name=val?/efg/name=val"), Value: 1}.Apply(doc)
230+
Expect(err).ToNot(HaveOccurred())
231+
232+
Expect(res).To(Equal([]interface{}{
233+
map[interface{}]interface{}{"xyz": "xyz"},
234+
map[interface{}]interface{}{
235+
"name": "val",
236+
"efg": []interface{}{
237+
map[interface{}]interface{}{"name": "val"},
238+
},
239+
},
240+
}))
241+
})
242+
211243
It("returns an error if it's not an array is being accessed", func() {
212244
_, err := ReplaceOp{Path: MustNewPointerFromString("/key=val")}.Apply(map[interface{}]interface{}{})
213245
Expect(err).To(HaveOccurred())
@@ -347,10 +379,10 @@ var _ = Describe("ReplaceOp.Apply", func() {
347379
It("returns an error if missing key needs to be created but next access does not make sense", func() {
348380
doc := map[interface{}]interface{}{"xyz": "xyz"}
349381

350-
_, err := ReplaceOp{Path: MustNewPointerFromString("/abc?/name=val"), Value: 1}.Apply(doc)
382+
_, err := ReplaceOp{Path: MustNewPointerFromString("/abc?/0"), Value: 1}.Apply(doc)
351383
Expect(err).To(HaveOccurred())
352384
Expect(err.Error()).To(Equal(
353-
"Expected to find key or after last index token at path '/abc?/name=val'"))
385+
"Expected to find key, matching index or after last index token at path '/abc?/0'"))
354386
})
355387

356388
It("returns an error if it's not a map when key is being accessed", func() {

patch/tokens.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ type AfterLastIndexToken struct{}
1313
type MatchingIndexToken struct {
1414
Key string
1515
Value string
16+
17+
Optional bool
1618
}
1719

1820
type KeyToken struct {
19-
Key string
21+
Key string
22+
2023
Optional bool
2124
}

0 commit comments

Comments
 (0)