Skip to content

Commit 9d6c29d

Browse files
authored
Add a function to perform case-insensitive search in mapstr.M (#244)
`FindFold` (similar to `strings.EqualFold`) traverses the map and tries to perform a case-insensitive match of each key segment on each map level.
1 parent 26a9ae3 commit 9d6c29d

File tree

2 files changed

+171
-4
lines changed

2 files changed

+171
-4
lines changed

mapstr/mapstr.go

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ const (
4141
var (
4242
// ErrKeyNotFound indicates that the specified key was not found.
4343
ErrKeyNotFound = errors.New("key not found")
44+
// ErrKeyCollision indicates that during the case-insensitive search multiple keys matched
45+
ErrKeyCollision = errors.New("key collision")
46+
// ErrNotMapType indicates that the given value is not map type
47+
ErrNotMapType = errors.New("value is not a map")
4448
)
4549

4650
// EventMetadata contains fields and tags that can be added to an event via
@@ -172,6 +176,62 @@ func (m M) HasKey(key string) (bool, error) {
172176
return hasKey, err
173177
}
174178

179+
// FindFold accepts a key and traverses the map trying to match every key segment
180+
// using `strings.FindFold` (case-insensitive match) and returns the actual
181+
// key of the map that matched the given key and the value stored under this key.
182+
// Returns `ErrKeyCollision` if multiple keys match the same request.
183+
// 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, ".")
186+
// the initial value must be `true` for the first iteration to work
187+
found := true
188+
// start with the root
189+
current := m
190+
// allocate only once
191+
var mapType bool
192+
193+
for i, segment := range path {
194+
if !found {
195+
return "", nil, ErrKeyNotFound
196+
}
197+
found = false
198+
199+
// we have to go through the list of all key on each level to detect case-insensitive collisions
200+
for k := range current {
201+
if !strings.EqualFold(segment, k) {
202+
continue
203+
}
204+
// if already found on this level, it's a collision
205+
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)
207+
}
208+
209+
// mark for collision detection
210+
found = true
211+
212+
// build the result with the currently matched segment
213+
matchedKey += k
214+
value = current[k]
215+
216+
// if it's the last segment, we don't need to go deeper
217+
if i == len(path)-1 {
218+
continue
219+
}
220+
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])
226+
if !mapType {
227+
return "", nil, fmt.Errorf("cannot continue path %q (full: %q), next element is not a map: %w", matchedKey, key, ErrNotMapType)
228+
}
229+
}
230+
}
231+
232+
return matchedKey, value, nil
233+
}
234+
175235
// GetValue gets a value from the map. If the key does not exist then an error
176236
// is returned.
177237
func (m M) GetValue(key string) (interface{}, error) {
@@ -266,10 +326,12 @@ func (m M) Format(f fmt.State, c rune) {
266326
// Flatten flattens the given M and returns a flat M.
267327
//
268328
// Example:
269-
// "hello": M{"world": "test" }
329+
//
330+
// "hello": M{"world": "test" }
270331
//
271332
// This is converted to:
272-
// "hello.world": "test"
333+
//
334+
// "hello.world": "test"
273335
//
274336
// This can be useful for testing or logging.
275337
func (m M) Flatten() M {
@@ -299,10 +361,12 @@ func flatten(prefix string, in, out M) M {
299361
// FlattenKeys flattens given MapStr keys and returns a containing array pointer
300362
//
301363
// Example:
302-
// "hello": MapStr{"world": "test" }
364+
//
365+
// "hello": MapStr{"world": "test" }
303366
//
304367
// This is converted to:
305-
// ["hello.world"]
368+
//
369+
// ["hello.world"]
306370
func (m M) FlattenKeys() *[]string {
307371
out := make([]string, 0)
308372
flattenKeys("", m, &out)

mapstr/mapstr_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,3 +1107,106 @@ func TestFormat(t *testing.T) {
11071107
})
11081108
}
11091109
}
1110+
1111+
func TestFindFold(t *testing.T) {
1112+
field1level2 := M{
1113+
"level3_Field1": "value2",
1114+
}
1115+
field1level1 := M{
1116+
"non_map": "value1",
1117+
"level2_Field1": field1level2,
1118+
}
1119+
1120+
input := M{
1121+
// baseline
1122+
"level1_Field1": field1level1,
1123+
// fold equal testing
1124+
"Level1_fielD2": M{
1125+
"lEvel2_fiEld2": M{
1126+
"levEl3_fIeld2": "value3",
1127+
},
1128+
},
1129+
// collision testing
1130+
"level1_field2": M{
1131+
"level2_field2": M{
1132+
"level3_field2": "value4",
1133+
},
1134+
},
1135+
}
1136+
1137+
cases := []struct {
1138+
name string
1139+
key string
1140+
expKey string
1141+
expVal interface{}
1142+
expErr string
1143+
}{
1144+
{
1145+
name: "returns normal key, full match",
1146+
key: "level1_Field1.level2_Field1.level3_Field1",
1147+
expKey: "level1_Field1.level2_Field1.level3_Field1",
1148+
expVal: "value2",
1149+
},
1150+
{
1151+
name: "returns normal key, partial match",
1152+
key: "level1_Field1.level2_Field1",
1153+
expKey: "level1_Field1.level2_Field1",
1154+
expVal: field1level2,
1155+
},
1156+
{
1157+
name: "returns normal key, one level",
1158+
key: "level1_Field1",
1159+
expKey: "level1_Field1",
1160+
expVal: field1level1,
1161+
},
1162+
{
1163+
name: "returns case-insensitive full match",
1164+
key: "level1_field1.level2_field1.level3_field1",
1165+
expKey: "level1_Field1.level2_Field1.level3_Field1",
1166+
expVal: "value2",
1167+
},
1168+
{
1169+
name: "returns case-insensitive partial match",
1170+
key: "level1_field1.level2_field1",
1171+
expKey: "level1_Field1.level2_Field1",
1172+
expVal: field1level2,
1173+
},
1174+
{
1175+
name: "returns case-insensitive one-level match",
1176+
key: "level1_field1",
1177+
expKey: "level1_Field1",
1178+
expVal: field1level1,
1179+
},
1180+
{
1181+
name: "returns collision error",
1182+
key: "level1_field2.level2_field2.level3_field2",
1183+
expErr: "collision",
1184+
},
1185+
{
1186+
name: "returns non-map error",
1187+
key: "level1_field1.non_map.some_key",
1188+
expErr: "next element is not a map",
1189+
},
1190+
{
1191+
name: "returns non-found error",
1192+
key: "level1_field1.not_exists.some_key",
1193+
expErr: "key not found",
1194+
},
1195+
}
1196+
1197+
for _, tc := range cases {
1198+
t.Run(tc.name, func(t *testing.T) {
1199+
key, val, err := input.FindFold(tc.key)
1200+
if tc.expErr != "" {
1201+
require.Error(t, err)
1202+
assert.Contains(t, err.Error(), tc.expErr)
1203+
assert.Nil(t, val)
1204+
assert.Empty(t, key)
1205+
return
1206+
}
1207+
require.NoError(t, err)
1208+
assert.Equal(t, tc.expKey, key)
1209+
assert.Equal(t, tc.expVal, val)
1210+
})
1211+
}
1212+
}

0 commit comments

Comments
 (0)