Lenspath: editing deeply nested structures
2023 Sep 18
See all posts
Lenspath: editing deeply nested structures
Functional programming offers a variety of constructs like data
structures and higher-order functions. Some of them are brought over to
the imperitive languages. Streams (delayed lists), and higher-order
functions like map, filter, collect are prime examples of such
imports.
Once I found myself needing to unmarshal a deeply nested structure in
golang – the structure involved arrays and maps, and had to be sent over
the wire (serialized). Consider the following example:
{
"elements": 1,
"endBlock": 15084335,
"replicaEvent": [
{
"data": {
"Hash": "0x6cae5c317d2e9b2f83c831227d244cf898802da0f92225699ccc79a26d66b15e",
"Header": {
"baseFeePerGas": 48090598751,
"difficulty": 11905180535994968,
"extraData": "cG9vbGluLmNvbR+NhcRb3CgzTQ==",
"gasLimit": 29970705,
"gasUsed": 1141848,
"logsBloom": [
16, 33, 64, 0, 0, 0, 24, 0, 16, 8, 2, 0, 128, 17, 1, 0, 0, 96, 0, 0, 0, 1, 0, 0, 0, 4, 0, 0, 48, 0, 8, 0, 0, 0, 1, 0, 6, 0, 0, 1, 8, 0, 16, 0, 0, 0, 0, 4, 34, 0, 128, 0, 8, 32, 25, 16, 0, 0, 0, 64, 0, 50, 0, 0, 0, 4, 0, 0, 4, 0, 1, 32, 72, 2, 0, 40, 0, 0, 65, 96, 0, 128, 0, 0, 4, 0, 16, 0, 160, 0, 4, 1, 128, 0, 144, 2, 2, 0, 0, 0, 2, 0, 64, 0, 0, 0, 1, 0, 0, 32, 8, 66, 0, 5, 128, 64, 0, 48, 0, 0, 48, 0, 0, 48, 8, 0, 0, 64, 0, 16, 1, 2, 2, 160, 0, 0, 0, 16, 128, 16, 0, 16, 0, 0, 4, 0, 64, 1, 1, 0, 0, 8, 0, 40, 0, 64, 72, 0, 0, 0, 2, 1, 0, 0, 1, 2, 36, 2, 8, 192, 64, 0, 5, 0, 0, 0, 0, 36, 0, 0, 0, 2, 2, 1, 0, 0, 0, 4, 0, 0, 0, 0, 0, 1, 0, 2, 2, 4, 1, 8, 0, 0, 0, 8, 0, 32, 64, 64, 2, 0, 0, 0, 0, 0, 0, 16, 0, 0, 32, 0, 4, 0, 184, 1, 0, 56, 32, 8, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 0, 1, 2, 0, 0, 0, 0, 96, 0, 64, 0, 64, 0, 0, 0, 4, 48, 2
],
"difficulty": 11905180535994968,
"number": 15084335,
"gasLimit": 29970705,
"gasUsed": 1141848,
"timestamp": 1657048078,
"extraData": "cG9vbGluLmNvbR+NhcRb3CgzTQ==",
"mixHash": "0x5ab6399ba49cdf5f34ba2396f59d1113468e4ba7b328765e74de5c9089292968",
"nonce": [
47,
141,
202,
109,
90,
237,
119,
225
],
"baseFeePerGas": 48090598751
},
"Transactions": [
{
"nonce": 14520,
"gasPrice": 97608830985,
"gas": 30000,
"from": "0x0000000000000000000000000000000000000000",
"to": "0x903c26a3d690bf010fd6441328983925852ffe4c",
"value": 0,
"input": ""
},
{
"nonce": 178,
"gasPrice": 60885866905,
"gas": 727188,
"from": "0x6b97445c501ec44bcda147d844bee0b13ec46545",
"to": "0x3b2a8583d381845d278fb75345e39c64891b3611",
"value": 0,
"input": "rWrIGwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC"
},
...
(The complete example is available here)
Before serialization, we needed to modify the structure at specific
points (or paths). Initially, we used a brute-force approach of raw
traversal with conditional checks, ultimately reaching the
point-of-interest path leaf, and making the edit. This was blowing up
into ugly code as we iterated on the structure.
// UnwrapAvroUnion "unwraps" the "to" field from the replica map
func UnwrapAvroUnion(data map[string]interface{}) map[string]interface{} {
vs := data
for k1 := range data {
if k1 == "replicaEvent" {
m1 := data[k1].([]interface{})
vsr := m1
for k2 := range m1 {
m2 := m1[k2].(map[string]interface{})
vso := m2
for k3 := range m2 {
if k3 == "data" {
m3 := m2[k3].(map[string]interface{})
vsd := m3
for k4 := range m3 {
switch k4 {
case "Transactions":
m4 := m3[k4].([]interface{})
vst := m4
for k5 := range m4 {
m5 := m4[k5].(map[string]interface{})
vsm := make(map[string]interface{})
for k6, v6 := range m5 {
switch {
case (k6 == "to" || k6 == "from") && v6 != nil:
m6 := v6.(map[string]interface{})
if v7, ok := m6["string"]; ok {
vsm[k6] = v7
}
case (k6 == "v" || k6 == "r" || k6 == "s") && v6 != nil:
m6 := v6.(map[string]interface{})
if v7, ok := m6["bytes"]; ok {
vsm[k6] = v7
}
default:
vsm[k6] = v6
}
}
vst[k5] = vsm
}
vsd[k4] = vst
case "Header":
m4 := m3[k4].(map[string]interface{})
vst := m4
for k5, v5 := range m4 {
if k5 == "withdrawalsRoot" && v5 != nil {
m5 := v5.(map[string]interface{})
if v6, ok := m5["string"]; ok {
vst[k5] = v6
}
}
}
vsd[k4] = vst
case "Withdrawals", "Uncles":
m4 := m3[k4].(map[string]interface{})
if m3[k4] == nil {
vsd[k4] = nil
} else {
vsd[k4] = m4["array"]
}
}
}
vso[k3] = vsd
}
}
vsr[k2] = vso
}
vs[k1] = vsr
}
}
return vs
}
This is where lenspath comes in.
What is lenspath
Lenspaths or simply Lenses,
captures the description of a path through an arbitrary structure. Once
a lenspath is defined, one can edit the value at the end of such a
lenspath. Consider this example from the golang lenspath
library:
data := map[string]any{
"name": "chacha",
"region": "India",
"additional": map[string]any{
"birthmark": "cut on the left hand",
"addi": map[string]string{
"code": "334532",
"landmark": "near the forest entry",
},
},
}
codePath, _ := Create([]string{"additional", "addi", "code"})
value, _ := codePath.Get(data)
fmt.Println(value) // "334532"
codePath.Set(data, "5A-1001")
value, _ = codePath.Get(data)
fmt.Println(value) // "5A-1001"
Here, the codePath
captures the path
additional -> addi -> code
. Note that defining this
lenspath is decoupled from the actual structure that needs to be
traversed. Once such a path is available, one can edit or retrieve the
value using Get
or Set
calls. You can also
have ways to compose the lenspath. For the example json given at the
start of the post, we define the following list of lenspaths:
var dataLens = createLenspath([]string{"replicaEvent", "*", "data"})
var transactionsLens = composeLenspath(dataLens, []string{"Transactions", "*"})
var vLens = composeLenspath(transactionsLens, []string{"v"})
var rLens = composeLenspath(transactionsLens, []string{"r"})
var sLens = composeLenspath(transactionsLens, []string{"s"})
var toLens = composeLenspath(transactionsLens, []string{"to"})
var fromLens = composeLenspath(transactionsLens, []string{"from"})
var headerLens = composeLenspath(dataLens, []string{"Header"})
var withdrawalsRootLens = composeLenspath(headerLens, []string{"withdrawalsRoot"})
var withdrawalsLens = composeLenspath(dataLens, []string{"Withdrawals"})
var uncleLens = composeLenspath(dataLens, []string{"Uncles"})
func composeLenspath(prevLenspath *lensp.Lenspath, lens []lensp.Lens) *lensp.Lenspath {
lenspath, err := prevLenspath.Compose(lens)
if err != nil {
log.Fatal(err)
return nil
}
return lenspath
}
A thing to note is the *
"node" when defining the
dataLens
path.
var dataLens = createLenspath([]string{"replicaEvent", "*", "data"})
This is a fanout operation, which indicates that the
previous node (replicaEvent
) is an array, and that the path
traverses into all elements of replicaEvent (and then into
data
node).
Now, constructing such a library might be easier in a
functional programming language like Haskell. Plently of higher-order
operations like map
and filter
made their way
into imperative languages because of their utility. For us, lenses was
something that (if possible) could be brought into golang and give major
benefits. This would need using golang reflection API.
laws of reflection in golang
I'm not going to deep dive into golang reflection here, as there's
already a superb
article on this. The golang reflection API provides two important
sructures - reflect.Type
and reflect.Value
.
These two structures (and the corresponding APIs they expose), allows
recursive traversal along the structure (map or struct), and retrieval
of value from an interface type.
the callback API in golang
lenspath
The golang lenspath also provides a callback API for get and set:
var dataLens = createLenspath([]string{"replicaEvent", "*", "data"})
index = 0
err := dataLens.Setter(map, func(value any) any {
index=index+1
return index ## nth leaf on the same level
})
This API allows for more complex editing/retrieval of leaf node
values (the simpler Get
/Set
are infact
expressed in terms of Setter
/Getter
)
refactoring and final look
The earlier messy code for editing the golang map can then be
replaced by:
// UnwrapAvroUnion unwraps avro wrapped maps
func UnwrapAvroUnion(data map[string]interface{}) map[string]interface{} {
if data == nil {
return nil
}
// v, r, s
unwrapType(data, vLens, "bytes")
unwrapType(data, rLens, "bytes")
unwrapType(data, sLens, "bytes")
// to, from
unwrapType(data, toLens, "string")
unwrapType(data, fromLens, "string")
// withdrawalsRoot
unwrapType(data, withdrawalsRootLens, "string")
// withdrawals, uncles
unwrapType(data, withdrawalsLens, "array")
unwrapType(data, uncleLens, "array")
return data
}
func unwrapType(data map[string]interface{}, lenspath *lensp.Lenspath, nonNilType string) {
lenspathSetter(data, lenspath, func(leafd any) any {
if leafd == nil {
return nil
}
mp := leafd.(map[string]interface{})
return mp[nonNilType]
})
}
Lenspath: editing deeply nested structures
2023 Sep 18 See all postsFunctional programming offers a variety of constructs like data structures and higher-order functions. Some of them are brought over to the imperitive languages. Streams (delayed lists), and higher-order functions like map, filter, collect are prime examples of such imports.
Once I found myself needing to unmarshal a deeply nested structure in golang – the structure involved arrays and maps, and had to be sent over the wire (serialized). Consider the following example:
(The complete example is available here)
Before serialization, we needed to modify the structure at specific points (or paths). Initially, we used a brute-force approach of raw traversal with conditional checks, ultimately reaching the point-of-interest path leaf, and making the edit. This was blowing up into ugly code as we iterated on the structure.
This is where lenspath comes in.
What is lenspath
Lenspaths or simply Lenses, captures the description of a path through an arbitrary structure. Once a lenspath is defined, one can edit the value at the end of such a lenspath. Consider this example from the golang lenspath library:
Here, the
codePath
captures the pathadditional -> addi -> code
. Note that defining this lenspath is decoupled from the actual structure that needs to be traversed. Once such a path is available, one can edit or retrieve the value usingGet
orSet
calls. You can also have ways to compose the lenspath. For the example json given at the start of the post, we define the following list of lenspaths:A thing to note is the
*
"node" when defining thedataLens
path.This is a fanout operation, which indicates that the previous node (
replicaEvent
) is an array, and that the path traverses into all elements of replicaEvent (and then intodata
node).Now, constructing such a library might be easier in a functional programming language like Haskell. Plently of higher-order operations like
map
andfilter
made their way into imperative languages because of their utility. For us, lenses was something that (if possible) could be brought into golang and give major benefits. This would need using golang reflection API.laws of reflection in golang
I'm not going to deep dive into golang reflection here, as there's already a superb article on this. The golang reflection API provides two important sructures -
reflect.Type
andreflect.Value
. These two structures (and the corresponding APIs they expose), allows recursive traversal along the structure (map or struct), and retrieval of value from an interface type.the callback API in golang lenspath
The golang lenspath also provides a callback API for get and set:
This API allows for more complex editing/retrieval of leaf node values (the simpler
Get
/Set
are infact expressed in terms ofSetter
/Getter
)refactoring and final look
The earlier messy code for editing the golang map can then be replaced by: