We all need recursion sometimes, but we can often wait a while. This is one of those situations when I do a slight bit of TDD. I start with the most banal example possible:
lang-ruby
RSpec.describe Mongo::LineConverter do
let(:converter) { described_class.new(conversions, data) }
let(:conversions) { [{ from: "_id.$oid", to: "mongo_id" }] }
let(:data) do
{
"_id" => { "$oid" => "12345" },
"name" => "Test Name",
}
end
describe "#convert" do
subject(:convert) { converter.convert }
it "converts data according to conversions" do
expect(convert["mongo_id"]).to eq("12345")
expect(convert["name"]).to eq("Test Name")
expect(convert).not_to include("_id")
end
end
endI designed the following code; the TDD part has yet to start.
lang-ruby
module Mongo
class LineConverter
def self.convert(...)
new(...).convert
end
attr_reader :data, :conversions
def initialize(conversions, data)
@conversions = conversions
@data = data
end
def convert
data.each_with_object({}) do |(key, value), memo|
conversions.each do |conversion|
froms = conversion[:from].split(".")
from = froms.first # I can't accept .first twice
to = conversion[:to]
if key == from
memo[to] = data.dig(*froms)
else
memo[key] = value
end
end
end
end
end
endObviously, this code only satisfies when the key is at the root level. We need to check this. I loaded a more complete example, and low and behold, I discovered some nested "_id" keys that I didn't want.
This is one of the very few times I reach for test-driven design. I have a solid design, but I need to expand on it. From here, adding tests to cover new scenarios is a way to verify that my design holds up.
This time it didn't hold up, so let's get into the details.
This is one of the very few times I reach for test-driven design. I have a solid design, but I need to expand on it. From here, adding tests to cover new scenarios is a way to verify that my design holds up.
This time it didn't hold up, so let's get into the details.
Adding test coverage
lang-ruby
it "converts data according to conversions" do
expect(convert["mongo_id"]).to eq("12345")
expect(convert).not_to include("_id")
expect(convert["nested"]["mongo_id"]).to eq("54321")
expect(convert["nested"]).not_to include("_id")
expect(convert["name"]).to eq("Test Name")
endFirst, I need to add recursion, but it is too complex with the existing implementation.
Can you spot the problem?
The convert method isn't recursive because it does not accept parameters! I could add a separate method or I change the methods signature. Personally, I prefer the latter, so let's go ahead and do that, and let's start with the test.
lang-ruby
RSpec.describe Mongo::LineConverter do
let(:converter) { described_class.new(conversions) }
let(:conversions) { [{ from: "_id.$oid", to: "mongo_id" }] }
let(:data) do
{
"_id" => { "$oid" => "12345" },
"nested" => { "_id" => { "$oid" => "12345" } },
"name" => "Test Name",
}
end
describe "#convert" do
subject(:convert) { converter.convert(data) }
it "converts data according to conversions" do
expect(convert["mongo_id"]).to eq("12345")
expect(convert).not_to include("_id")
expect(convert["nested"]["mongo_id"]).to eq("54321")
expect(convert["nested"]).not_to include("_id")
expect(convert["name"]).to eq("Test Name")
end
end
endI struggled a little; I asked ChatGPT to help because I am soft, and recursion is hard. Unfortunately, ChatGPT was less than helpful. Its way of dealing with recursion could have been more comprehensible.
I ended up with the following (which I am super proud of as it is all mine).
I ended up with the following (which I am super proud of as it is all mine).
lang-ruby
module Mongo
class LineConverter
def self.convert(conversions, data)
new(conversions).convert(data)
end
attr_reader :conversions
def initialize(conversions)
@conversions = conversions
end
def convert(data)
case data
when Hash
data.each_with_object({}) do |(key, value), memo|
# NOTE: We need to track the state of conversion.
converted = false
# NOTE: Loop through the conversions and convert the data.
conversions.each do |conversion|
# NOTE: To support nested lookups we suppport "_id.$oid" as a from key.
froms = conversion[:from].split(".")
from = froms.first
to = conversion[:to]
# NOTE: This key needs no conversion.
next unless from == key
new_value = data.dig(*froms)
memo[to] = convert(new_value)
# NOTE: This key was converted, which ensures we can skip it below
converted = true
break # no need to continue the loop
end
# NOTE: If this key wasn't converted, recursively convert its value.
memo[key] = convert(value) unless converted
end
when Array
data.map { |element| convert(element) }
else
data
end
end
end
end
