Breaking down those steps

Previously I wrote about Baby stepping into a solution to find URL-based references to assets for Conductor.  There were actually a lot of small steps involved in producing the solution. I figure what might help is to break-down the steps of my most recent work.

“Natural Language” Date Ranges

tower of babel

"De toren van Babel, Pieter Bruegel de Oude" courtesy of janke

I had received a request for creating custom date ranges: Could you only show events from the 2010 admissions calendar year? Or could you group events by 2010 academic year? Could you group events by academic semester? Could you break down events by quarter?  Nothing to complicated … Except where do you store those rules?  And do we really mean 2010 or does that mean “current year.”

Possible Solutions

After a bit of brain-storming with Chris, another developer of AgencyND, we came up with a couple of options.

Hard-Coding

stone house

"The true Stone House" courtesy of Feliciano Guimarães

Rarely will you hear someone extoll the virtues of hard-coding; in fact it is considered an anit-pattern, something that may be commonly used but is likely detrimental to the long-term health of the system. In layman’s terms, hard-coding is indicative of exhausted design options, short-term solutions, and/or buzzer-beating patches to a system.  It won’t hurt you now, but the you of four years from now might very well want to find a Delorean, get up to 88 miles per hour, and have a few choice words with your present self.

Needless to say, I personally didn’t want to implement a hard-coded solution without first exploring a Domain-Specific solution.

Domain-Specific solution

The domain specific solution is to look for a common means of expression.  Is there a sentence/phrase structure that can help convey the meaning to people and be formal enough that a computer can parse the phrase?  My team explored a bit, and came up with a couple of different phrases that we could use:

  • “starting on 9/14 for 1 year as of 2 years ago”
  • “starting no later than 1 year after 10/1 of 2 years ago”
  • “as of 2 years ago on 10/1 and continuing until 1 year later”
  • “on 10/2 for 1 year as of 2 years ago”

These phrases are not necessarily intended for “public” consumption, but would instead be used primarily by web developers. However, we don’t want the syntax to be overly archaic.  Regardless of which language we chose, the above sentences lead to the next step.

Define the Domain

domain

courtesy of rubyblossom

If you squint your eyes there are 6 variables used in calculating the custom date range:

  • starting_month
  • starting_day
  • starting_time_quantity
  • starting_time_unit
  • duration_quantity
  • duration_unit

With a little substitution on the first option, we have the following:

“starting on starting_month/starting_day for duration_quantity duration_unit as of starting_time_quantity starting_time_unit ago”

Define the Expected Results

At this point, I went down a path of short-lived frustration. I immediately jumped into trying to fetch events whose dates fell within the date range, but excluded events that didn’t.  Ultimately I was trying to do too many things in one function and test. Fortunately, when unit testing, one of the senses I’ve developed is “if I have to do too much to setup a test, then I clearly am not doing something right.”  So I decided to break both the function and the test into smaller parts.  There would be one test for retrieving by date range, and one test for creating the date range based on the natural language. The challenge wasn’t retrieving events with a given date range; Ruby on Rail‘s Object Relational Mapper ActiveRecord makes that trivial.  The challenge was parsing the sentence correctly.  To do this, I spec-ed out a test that I wanted to build towards:

should_parse_date_range(
  "starting on 9/14 for 1 year as of today",
   :as_of => '2010/09/15',
   :start_date => "2010/09/14",
   :end_date => "2011/09/14"
)

Breaking the above down into it’s components:

should_parse_date_range
This is the method that will verify the result.
"starting on 9/14 for 1 year as of today"
This is the test case I’m wanting to verify.
:as_of => "2010/09/15"
This is the date (i.e. “today”) for which the range is calculated.
:start_date => "2010/09/14"
This is the expected start date of the date range, relative to the :as_of date.
:end_date => "2011/09/14"
This is the expected end date of the date range, relative to the :as_of date.

Then I began to plug away at the implementation. And settled on the following Regular Expression to parse the sentence:

/^starting on (\d{1,2})-(\d{1,2}) for (\d+) (year|month|day)s? as of (today|now)$/

or in other terms

“starting on starting_month/starting_day for duration_quantity duration_unit as of starting_time_quantity starting_time_unit ago”

Then, armed with Ruby on Rail’s ActiveSupport Time library I wrote the following code.

# starting_month set from 1st matched Regular Expression group
# starting_day set from 2nd matched Regular Expression group
# duration_quantity set from 3rd matched Regular Expression group
# duration_unit set from 4th matched Regular Expression group

today = Date.today
starting_time_quantity = 0
starting_time_unit = 'years'

# 1.year.ago(10/10/2010).beginning_of_day.in_time_zone
date_range_begin_date = starting_time_quantity.
  send(starting_time_unit).
  ago(today).
  beginning_of_day.in_time_zone

begin_date = Date.civil(
  date_range_begin_date.year, 
  starting_month, 
  starting_day
).beginning_of_day.
  in_time_zone

if begin_date > date_range_begin_date
  begin_date = 1.year.ago(begin_date)
end

# 1.year.since(10/10/2010).beginning_of_day.in_time_zone
end_date = duration_quantity.
  send(duration_unit).
  since(begin_date).
  beginning_of_day.
  in_time_zone

Range.new(begin_date,end_date)

It took a bit to get to the above algorithm, but it was done by incrementally adding test cases; Each added test case stated the desired output based on the actual input. And those tests are now run daily as part of the larger test suite. So I can forget about it and move on…until I need to remember what was happening; Then I simply refer to the tests to see what is actually happening.