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 end
I 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 end
Obviously, 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") end
First, 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 end
I 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