Skip to content

Commit a35b0a2

Browse files
authored
Add AlterPath and Traverse functions to mapstr.M (#247)
This allows to make changes to the existing keys easier. For example, switching casing of the keys.
1 parent 7332384 commit a35b0a2

File tree

2 files changed

+329
-20
lines changed

2 files changed

+329
-20
lines changed

mapstr/mapstr.go

Lines changed: 109 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -181,55 +181,145 @@ func (m M) HasKey(key string) (bool, error) {
181181
// key of the map that matched the given key and the value stored under this key.
182182
// Returns `ErrKeyCollision` if multiple keys match the same request.
183183
// Returns `ErrNotMapType` when one of the values on the path is not a map and cannot be traversed.
184-
func (m M) FindFold(key string) (matchedKey string, value interface{}, err error) {
185-
path := strings.Split(key, ".")
184+
// Returns `ErrKeyNotFound` when the path does not exist
185+
func (m M) FindFold(path string) (matchedKey string, value interface{}, err error) {
186+
segmentCount := strings.Count(path, ".") + 1
187+
err = m.Traverse(path, CaseInsensitiveMode, func(level M, key string) error {
188+
segmentCount--
189+
matchedKey += key
190+
if segmentCount != 0 {
191+
matchedKey += "."
192+
return nil
193+
}
194+
195+
value = level[key]
196+
return nil
197+
})
198+
if err != nil {
199+
return "", nil, err
200+
}
201+
return matchedKey, value, nil
202+
}
203+
204+
type AlterFunc func(string) (string, error)
205+
206+
// AlterPath walks the given `path` and replaces matching keys using the value returned by `alterFunc`.
207+
// `mode` sets the behavior how the given path is matched throughout the levels.
208+
// Returns `ErrKeyCollision` if multiple keys match the same request (when `mode` is `CaseInsensitiveMode`).
209+
// Returns `ErrNotMapType` when one of the values on the path is not a map and cannot be traversed.
210+
// Returns `ErrKeyNotFound` when the path does not exist
211+
func (m M) AlterPath(path string, mode TraversalMode, alterFunc AlterFunc) (err error) {
212+
return m.Traverse(path, mode, func(level M, key string) error {
213+
val := level[key]
214+
newKey, err := alterFunc(key)
215+
if err != nil {
216+
return fmt.Errorf("failed to apply a change to %q: %w", key, err)
217+
}
218+
if newKey == "" {
219+
return fmt.Errorf("replacement key for %q cannot be empty", key)
220+
}
221+
_, exists := level[newKey]
222+
if exists {
223+
return fmt.Errorf("replacement key %q already exists: %w", newKey, ErrKeyCollision)
224+
}
225+
delete(level, key)
226+
level[newKey] = val
227+
228+
return nil
229+
})
230+
}
231+
232+
// TraversalMode used for traversing the map through multiple levels.
233+
type TraversalMode int
234+
235+
const (
236+
// The key match is strictly case-sensitive
237+
CaseSensitiveMode = iota
238+
// The key match is performed with `strings.EqualFold`
239+
CaseInsensitiveMode = iota
240+
)
241+
242+
type TraversalVisitor func(M, string) error
243+
244+
// Traverse walks the given nested `path` in the map and invokes the `visitor` function on each level passing
245+
// the current-level map and the current key.
246+
// `mode` sets the behavior how the given path is matched throughout the levels.
247+
// The `visitor` function is allowed to make changes in the level or collect data.
248+
// Returns `ErrKeyCollision` if multiple keys match the same request (when `mode` is `CaseInsensitiveMode`).
249+
// Returns `ErrNotMapType` when one of the values on the path is not a map and cannot be traversed.
250+
// Returns `ErrKeyNotFound` when the path does not exist
251+
func (m M) Traverse(path string, mode TraversalMode, visitor TraversalVisitor) (err error) {
252+
segments := strings.Split(path, ".")
253+
var match func(string, string) bool
254+
255+
switch mode {
256+
case CaseInsensitiveMode:
257+
match = strings.EqualFold
258+
case CaseSensitiveMode:
259+
match = func(a, b string) bool { return a == b }
260+
}
261+
186262
// the initial value must be `true` for the first iteration to work
187263
found := true
188264
// start with the root
189265
current := m
190266
// allocate only once
191-
var mapType bool
267+
var (
268+
mapType bool
269+
next interface{}
270+
)
192271

193-
for i, segment := range path {
272+
for i, segment := range segments {
194273
if !found {
195-
return "", nil, ErrKeyNotFound
274+
return ErrKeyNotFound
196275
}
197276
found = false
198277

199278
// we have to go through the list of all key on each level to detect case-insensitive collisions
200279
for k := range current {
201-
if !strings.EqualFold(segment, k) {
280+
if !match(segment, k) {
202281
continue
203282
}
283+
204284
// if already found on this level, it's a collision
205285
if found {
206-
return "", nil, fmt.Errorf("key collision on the same path %q, previous match - %q, another subkey - %q: %w", key, matchedKey, k, ErrKeyCollision)
286+
return fmt.Errorf("multiple keys match %q on the same level of the path %q: %w", k, path, ErrKeyCollision)
207287
}
208288

209289
// mark for collision detection
210290
found = true
211291

212-
// build the result with the currently matched segment
213-
matchedKey += k
214-
value = current[k]
292+
// we need to save this in case the visitor makes changes in keys
293+
next = current[k]
294+
err = visitor(current, k)
295+
if err != nil {
296+
return fmt.Errorf("error visiting key %q of the path %q: %w", k, path, err)
297+
}
215298

216-
// if it's the last segment, we don't need to go deeper
217-
if i == len(path)-1 {
299+
// if it's the last segment, we don't need to go deeper, skipping...
300+
if i == len(segments)-1 {
218301
continue
219302
}
220303

221-
// if it's not the last segment we put the separator dot
222-
matchedKey += "."
223-
224-
// go one level deeper
225-
current, mapType = tryToMapStr(current[k])
304+
// try to go one level deeper
305+
current, mapType = tryToMapStr(next)
226306
if !mapType {
227-
return "", nil, fmt.Errorf("cannot continue path %q (full: %q), next element is not a map: %w", matchedKey, key, ErrNotMapType)
307+
return fmt.Errorf("cannot continue path %q, next value %q is not a map: %w", path, k, ErrNotMapType)
308+
}
309+
310+
// if it's a case-sensitive key match, we don't have to care about collision detection
311+
// and we can simply stop iterating here.
312+
if mode == CaseSensitiveMode {
313+
break
228314
}
229315
}
230316
}
231317

232-
return matchedKey, value, nil
318+
if !found {
319+
return ErrKeyNotFound
320+
}
321+
322+
return nil
233323
}
234324

235325
// GetValue gets a value from the map. If the key does not exist then an error

0 commit comments

Comments
 (0)