On Dealing with Deep Hashes in Ruby — XF — Part Two: Traces

Brandon Weaver
4 min readApr 25, 2018

--

Xf is a Ruby gem meant for transforming and searching deep hashes, inspired loosely by Lenses in Haskell.

Xf is short for Transform Functions, or XForm Functions.

This is part two of the series, and this time we’re going to be looking into Traces in Xf.

A Trace is a way to dive down until you find a matching key, value, or both anywhere in a hash. It’s a concept born from trying to navigate large amounts of JSON that tend to stash the same named keys in variable areas, and needing to either get their values or change them all in one fell swoop.

Since map.map.compact.flat_map.compact.argh gets a bit tedious to do, Trace became my way to deal with it in a more succinct way. Let’s dive in to how they work!

Trace — Recursing Dive

The thing about Trace is it has to dive to every corner of a hash to get the values it cares about. Question is, how can we achieve that in a generic way that can be used for both getting and setting values on that Hash?

Three tools: yield, next, and recursion:

private def recursing_dive(target_hash, &fn)
target_hash.each { |k, v|
yield(target_hash, k, v) if match?(
target_hash, k, v, @trace_path
)
next unless target_hash[k].is_a?(Hash)
recursing_dive(target_hash[k], &fn)
}
end

Now how does this work? First, for reference, @trace_path is a single path:

class Trace
def initialize(trace_path)
@trace_path = trace_path
end

It takes in a target hash to dive into, and it checks if there’s a match as defined here:

private def match?(hash, key, value, matcher)
matcher === key
end

So why in the world would I pass in four values if I only need two of them? Well we’ll get to that later, but the short version is inheritance overrides for different types of Trace.

Anyways, if it happens to match (using === because it’s so danged powerful), that value gets yielded to the caller:

private def recursing_dive(target_hash, &fn)
target_hash.each { |k, v|
yield(target_hash, k, v) if match?(
target_hash, k, v, @trace_path
)
next unless target_hash[k].is_a?(Hash)
recursing_dive(target_hash[k], &fn)
}
end

Trace — Getter

So what’s the caller? Well we have both the getter and setter using it, but we’ll start with the getter first:

def get_value(hash)
retrieved_values = []
recursing_dive(hash) { |h, k, v| retrieved_values.push(v) }
retrieved_values
end

That’s it? Yep. All it does is dive through the hash and push whatever value it finds that matches into an Array we return back at the end.

Trace — Setter

Remember how we have access to the hash and key though? That comes in real handy for the setter method:

def set_value!(hash, value = nil, &fn)
recursing_dive(hash) { |h, k, v|
h[k] = block_given? ? yield(v) : value
}

hash
end

After it gets down to a matching value, we just overwrite that value with one of two things.

If it was called with a value:

Xf.trace(:deep_value).set(5)

Then every key matching :deep_value will now have its value set to 5.

If it was called with a block:

Xf.trace(:age).set { |v| v + 1 }

Well happy birthday to everyone in that list!

Note that set_value and get_value are the non-proc versions of the method. I would use currying like in more functional languages, but the performance penalties are a bit steep.

If you’re worried about the mutation of the hash remember I have a habit of using mutation methods to do the dirty work and using clone to mask the side-effects:

def set_value(hash, value = nil, &fn)
set_value!(deep_clone(hash), value, &fn)
end

So if you care about keeping things looking clean you’re set!

Now then, back to our recursing dive

Trace — Recursing

Now that we’ve seen what calls it, when does it decide to keep going?

private def recursing_dive(target_hash, &fn)
target_hash.each { |k, v|
yield(target_hash, k, v) if match?(
target_hash, k, v, @trace_path
)
next unless target_hash[k].is_a?(Hash)
recursing_dive(target_hash[k], &fn)
}
end

If the target value happens to be a Hash, we know there’s more to explore out there so grab your snorkel because it’s dive time!

Eventually I plan to refactor this a bit to deal with Arrays as well, but that may well be tempting the recursion stack demons a bit too much. Also target_hash[k] is literally v so there are most certainly other refactors to happen as well.

That’s pretty much all there is to a Trace though, check it out for yourself:

Wrapping Up

That’s it for part two, you now know how to make a recursive descent for hashes to find values! There are lots of fun things you can do with it, but with great power comes great hackery, mayhem, and apparently posts on Medium shortly afterwards to show it off.

Part three will likely cover Evolution from Rambda and how it’s potentially implemented in Ruby, but I kinda need to do it first so it may take a week or two to do properly:

Either that or more Qo work considering the Pattern Matching Renaissance in the Ruby bug tracker:

I might have written an entire post or two worth of content on pattern matching over there, fascinating stuff, you all should chime in too!

Well, that’s that. Go give Xf a try and let me know what you think.

Enjoy!

baweaver

--

--