Noel Rappin Writes Here

More Ruby Magic

Posted on September 22, 2021


Hey, if you like this post, you might like my recent books: “Modern Front-End Development for Rails” (Ebook) (Amazon) and “Modern CSS With Tailwind” (Ebook) (Amazon). If you’ve read and enjoyed either book, I would greatly appreciate your help by giving a rating on Amazon. Thanks!

When last I talked about the Elmer project tracker, I was talking about Ruby magic and singing the praises of StringInquirer. I was expecting some pushback on the Ruby Magic™, but didn’t get any, so I threatened to do something really weird, like implementing an API for getting project history with something metaprogrammy and dynamic, like project.1.day.ago.

The response was basically “sounds interesting, try it?". So, here we are. I’m not sure I’d do this on a team project, but I am happy with how it turned out…

The plans changed slightly as I was putting this together.

First, I was noodling on Twitter about whether to implement the syntax as project.1.day.ago or project.1_day_ago. The dot syntax is similar to the existing Rails 1.day.ago, but the underscore syntax is probably easier to implement because it’s just one message, where as in the dot syntax, you need to hold on to the intermediate values.

I was leaning toward the dot syntax. Then Joël Quenneville pointed out on Twitter that the dot syntax implies a chain of methods that you could stop at any time, and unlike the Rails version, where 1.day is a meaningful thing, it’s not clear that project.1 is meaningful.

Fair point.

As I was implementing this code, I also learned that the standardrb gem doesn’t like the project.1 syntax in either configuration because it statically analyzes .1 as a deprecated floating point number and wants it to be 0.1.

That’s fine, project.one_day_ago arguably reads better anyway.

I decided to implement the underscore syntax (though once it’s all in place, also supporting the dot syntax is not hard).

I also decided to add a few other flourishes, like. 1_business_day_ago and last_friday, and two_fridays_ago. For fun, we’re all friends here.

Because this feature has a very defined input and a very defined output, but the internal structure isn’t clear, it’s a great candidate for test-driven development.

The existing implementation in the class is project.at(Date.parse("2021-09-21"), so the simplest implementation is to have all the magic methods result in a date, and then call at with that date as an argument. The at method is already implemented and tested, so this is a prime candidate for TDD using RSpec mocks (setting up real data for backdating would be something of a pain).

Here’s my first set of tests:

  describe "magic history" do
    let(:team) { build(:team) }
    let(:project) { build(:project, team: team) }

    before do
      travel_to(Date.parse("Sep 23, 2021"))
    end

    it "handles date travel correctly" do
      expect(project).to receive(:at).with(Date.parse("Sep 23, 2021"))
      project.at(Date.current)
    end

    it "can go back one day" do
      expect(project).to receive(:at).with(Date.parse("Sep 22, 2021"))
      project.one_day_ago
    end

    it "can go back two days" do
      expect(project).to receive(:at).with(Date.parse("Sep 21, 2021"))
      project.two_days_ago
    end

    it "errors on nonsense" do
      expect { project.banana }.to raise_error(NoMethodError)
    end
  end

The travel_to at the beginning just makes sure there’s a consistent set of dates so I’m not doing everything with date math. The first test just checks that the mock works at all with dates (and probably will be deleted at the end), the next two tests test the most basic magic methods, and the last one makes sure that non-magic methods still raise errors.

Here’s my first run at making the tests pass:

  def respond_to_missing?(method_name, include_private = false)
    is_magic_history_method?(method_name)
  end

  def is_magic_history_method?(method_name)
    number, duration, time = method_name.to_s.split("_")
    return false if NumbersInWords.in_numbers(number).zero?
    return false unless duration.in?(%w[day days])
    return false unless time == "ago"
    true
  end

  def method_missing(method_name)
    super unless is_magic_history_method?(method_name)
    number_string, duration, _ = method_name.to_s.split("_")
    number = NumbersInWords.in_numbers(number_string)
    date = number.send(duration).ago
    at(date)
  end

I separated out is_magic_history_method? because at some point I might want to test against it, though it’s probably not necessary now.

To test for a magic history method, we split the string at each underscore, and expect three substrings. For the first part, I’m using the numbers_in_words gem to convert the string to a number (“one” -> 1). That gem, like Ruby’s to_i method, returns 0 if it doesn’t convert. If we can’t convert the first part of the string to a number other than zero, we fail out. The second part of the string is day or days and the third part has to be ago. This means that 2_day_ago is legal, but I’m willing to live with that.

The actual method_missing checks for valid magic history-ness, and if the method is still valid, it does a similar conversion, taking the digit, using send to call digit.day or digit.days, and then calling ago, so the eventual interpretation is that we wind up chaining something like 1.day.ago to get our final date.

Tests pass.

This is, to be clear, a lot of effort to turn project.one_day_ago into project.at(1.day.ago), but I’m not done yet.

Adding weeks, months, and years is a question of just adding the tests and adding the relevant keywords to the list in is_magic_history_method?.

Adding business_day and business_days is different, because the 1.day.ago logic won’t work, I need to take advantage of the business gem and its API for calculating business day math.

I need a test that will take us back far enough to jump over a weekend:

    it "can go back business days" do
      expect(project).to receive(:at).with(Date.parse("Sep 17, 2021"))
      project.four_business_days_ago
    end

A passing test and a slight refactor later, and I get this:

  def is_magic_history_method?(method_name)
    number, *duration, time = method_name.to_s.split("_")
    return false if NumbersInWords.in_numbers(number).zero?
    return false unless duration.join("_").in?(
      %w[day days week weeks month months year years business_day business_days]
    )
    return false unless time == "ago"
    true
  end

  def method_missing(method_name)
    super unless is_magic_history_method?(method_name)
    number_string, duration, _ = method_name.to_s.split("_")
    number = NumbersInWords.in_numbers(number_string)
    at(duration_from(number, duration))
  end

  def duration_from(number, duration)
    if duration == "business"
      Calculator.calendar.subtract_business_days(Date.current, number)
    else
      number.send(duration).ago
    end
  end

There are two changes here, the small one is that is_magic_history_method? needs to adjust its split structure because business_days has an underscore in it. More to the point, the real method now has to switch on business or not business because the logic is different.

Tests pass.

And now I like this more, because project.one_business_day_ago is much clearer than project.at(Calculator.calendar.subtract_business_days(Date.current, 1).

And I’d leave it there, except that I want to add last_friday and the like, which means that if statement is becoming a case based on type, which means we have an object here which means I’m backing myself into a natural language date processing system.

I really did not expect that when I started.

Refactoring out to a class hierarchy gives me this:

class DateInterpreter
  attr_reader :number, :duration, :time

  def self.for(method_name)
    subclasses.each do |subclass|
      result = subclass.for(method_name)
      return result if result
    end
    NullCalendarDateInterpreter.new
  end

  def initialize(number_string, duration, time)
    @number_string, @duration, @time = number_string, duration, time
    @number = NumbersInWords.in_numbers(@number_string)
  end

  def is_magic_history_method?
    return false if number.zero?
    return false unless time == "ago"
    return false unless duration.in?(self.class.words)
    true
  end

  class CalendarDateInterpreter < DateInterpreter
    def self.words = %w[day days week weeks month months year years]

    def self.for(method_name)
      number, duration, time = method_name.to_s.split("_")
      return nil unless duration.in?(words)
      new(number, duration, time)
    end

    def interpret
      number.send(duration).ago
    end
  end

  class BusinessDateInterpreter < DateInterpreter
    def self.words = %w[business_day business_days]

    def self.for(method_name)
      number, *duration, time = method_name.to_s.split("_")
      duration_string = duration.join("_")
      return nil unless duration_string.in?(words)
      new(number, duration_string, time)
    end

    def interpret
      Calculator.calendar.subtract_business_days(Date.current, number)
    end
  end

  class NullCalendarDateInterpreter
    def is_magic_history_method? = false

    def interpret = nil
  end
end

As usual, I’ve made it longer, but the code needed to add new features will be short and contained.

There’s a parent class DateInterpreter, which handles common logic, and then separate subclasses for each pattern that the code can handle. There’s a CalendarDateInterpreter which handles patterns like two_months_ago, and BusinessDateInterpreter, which handles three_business_days_ago.

The entry point is the for method of DateInterpreter, which is our dispatch method. Back when I was writing about value objects, the dispatch method was just a big case statement, then later it was refactored based on a static constant of possible values.

This time, rather than build up a case statement for dispatch, this code dispatches dynamically by querying all the subclasses.

In Ruby, the subclasses method of a class returns a list of subclasses. The parent for method loops over that list calling for on each subclass. The for method for each subclass returns an instance if the subclass handles that string, and nil if it doesn’t.

So the CalendarDateInterpter#for method checks to see if the second word of the method name is one of the calendar words, while the BusinessDateInterpter#for checks if the middle two words of the method name are business_day or business_days. (I realize there’s substantial duplication between those two methods, but I’m leaving it for the moment). Each subclass parses the method name as needed and provides its own interpret method to convert the method name to a date.

If none of the subclasses match the method name, then the NullCalendarDateInterpreter returns an instance with nullish behavior.

I really thought I’d have a regular expression here by now, but I tend to find split much easier to deal where I can.

The calling code in Project is much shorter.

  def respond_to_missing?(method_name, include_private = false)
    DateInterpreter.for(method_name).is_magic_history_method?
  end

  def method_missing(method_name)
    interpreter = DateInterpreter.for(method_name)
    super unless interpreter.is_magic_history_method?
    at(interpreter.interpret)
  end

And the tests pass again.

More to the point, it’s now clear how to add new features to this date interpreter. You add a subclass with a static for method that looks at the method name and decides if the subclass matches the pattern of the method name, and an interpret method that converts the bits of the method name to an actual date.

Lets try it with last_friday and its friends…

    it "can go back by day of week" do
      expect(project).to receive(:at).with(Date.parse("Sep 17, 2021"))
      project.last_friday
    end

    it "goes back one week if it's today" do
      expect(project).to receive(:at).with(Date.parse("Sep 16, 2021"))
      project.last_thursday
    end

    it "can go back by multiple days of week" do
      expect(project).to receive(:at).with(Date.parse("Sep 2, 2021"))
      project.two_thursdays_ago
    end

And here’s the passing class:

  class DayOfWeekDateInterpreter < DateInterpreter
    def self.words = %w[
      monday mondays tuesday tuesdays wednesday wednesdays
      thursday thursdays friday fridays saturday saturdays sunday sundays
    ]

    def self.for(method_name)
      number, weekday, time = method_name.to_s.split("_")
      return nil unless weekday.in?(words)
      new(number, weekday, time)
    end

    def weekday
      duration.ends_with?("s") ? duration[0...-1] : duration
    end

    def interpret
      date = Date.current.prev_occurring(weekday.to_sym)
      date.advance(weeks: -(number - 1))
    end
  end

The code starts with a list of days and plural days. Our for method is pretty similar to the others and it might be time to consolidate those. The interpret method borrows a couple of ActiveSupport date calculation methods to move to the previous incident of the weekday, and then drop back more weeks if needed.

I also need to make one change to how strings are converted to numbers to make sure that last is handled properly

  def parse_number
    return 1 if number_string == "last"
    NumbersInWords.in_numbers(number_string)
  end

This is fun.

Totally useless, but fun.

(Weirdly, I’m getting more sold on this as I go on. I think project.last_friday might be approaching genuinely useful?)

There’s one other thing that I think I want:

    it "can go to end of last month" do
      expect(project).to receive(:at).with(Date.parse("August 31, 2021"))
      project.end_of_last_month
    end

    it "can go to the end of two years ago" do
      expect(project).to receive(:at).with(Date.parse("December 31, 2019"))
      project.end_of_two_years_ago
    end

Our programmers were so concerned with whether they could, they never asked whether they should…

That one was actually easier than the previous one, although again that’s largely because we can piggyback on some ActiveSupport tooling:

  class EndOfInterpreter < DateInterpreter
    def self.words = %w[week weeks month months year years]

    def self.for(method_name)
      en, of, number, duration, time = method_name.to_s.split("_")
      return nil unless en == "end"
      return nil unless of == "of"
      return nil unless duration.in?(words)
      new(number, duration, time)
    end

    def canonical_duration
      duration.ends_with?("s") ? duration[0...-1] : duration
    end

    def interpret
      result = number.send(duration).ago
      result.send(:"end_of_#{canonical_duration}").to_date
    end
  end

The last bit dynamically sends ActiveSupport methods like end_of_year to the resulting date. Seems to work fine, though I bet there are some odd edge cases that I’m not going to worry too much about.

Have we learned anything here? Other than that I have like 100 lines of date interpreter code that are, at best, of dubious utility?

A couple things in favor of this code:

  • It was super fun to write.
  • It actually didn’t take that long, so it wasn’t a big investment even if it doesn’t go anywhere.
  • On a practical level, encapsulating date arithmetic actual seems like it might be useful when I start writing report code. Like, calling end_of_last_week, end_of_two_weeks_ago, end_of_three_weeks_ago, is starting to look like an actual report.

The main thing against it is that it’s quite magical. I haven’t written documentation (yet), but there’s no easily discoverable list of exactly what methods you can call to get at these date listings. If you saw project.end_of_last_week and wanted to find out how that works, you’d be in some trouble.

Some future directions might include allowing the dispatch method to take a real, space-delimited string rather than a method name, that almost makes this something you’d use in a calendar program generally.

Since this code depends on the original class only by calling its at method, it’d be pretty easy to move it to a module and allow it to be mixed in to any other class that defines an at method and wants history behavior.

Next time: Well, I’m not sure it’ll be the next post in general, but the next post on Elmer is going to be about a big data refactor. Don’t worry, it also has some metaprogramming.

If you want to get this in newsletter form, direct to your inbox, sign up at http://buttondown.email/noelrap.



Comments

comments powered by Disqus



Copyright 2024 Noel Rappin

All opinions and thoughts expressed or shared in this article or post are my own and are independent of and should not be attributed to my current employer, Chime Financial, Inc., or its subsidiaries.