More Ruby Magic
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.