TimeMath: ActiveSupport envy vs good selection of atomic concepts
When I’ve started to design time_math2 gem, I’ve already had a strong set of opinions in my head, of why and how it should be done. Like:
- concise set of well-defined operators for math arithmetics, easy to comprehend and remember;
- no invasion into Ruby core classes;
- code using TimeMath should be readable, unambiguous and short.
As for me, I’ve managed to achieve this for simpe operations from the very beginning:
TimeMath.day.floor(Time.now) # => current midnight
TimeMath.day.advance(Time.now, 4) # => 4 days since now
#... and so on.
But, it turned out, typical math arithmetics code frequently require several consequtive operations, like “2 hours and 5 minutes before current midnight”. Also, “amount of time to add to something” turned out to be useful abstraction.
And in those cases TimeMath were still much more verbose (in a bad way!
verbosity is not always a drawback) than ActiveSupport’s Time.now.midinight + 1.hour
and similar stuff.
In chase of ActiveSupport-alike expressiveness, I’ve made several false moves in previous version:
- added
TimeMath::Span
simple object, with usage likeTimeMath.day.span(4).before(Time.now)
; - as a last resort for desperates, added optional
core_ext.rb
, now allowingTime.now.advance_by(:day, 2)
.
Both steps were obviously false and superfluous, adding several ways of do the same with methods with different names:
TimeMath.day.advance(Time.now, 1)
TimeMath.day.span(1).after(Time.now)
Time.now.advance_by(:day, 1)
Okay, ActiveSupport tends to allow itself in all kind of “useful friendly
methods”, with all of midnight
, beginning_of_week
and others, but
we can do better! Or so I hope.
So, let me introduce you to time math operation. It is:
- chainable;
- uses exactly the same names and abstraction as atomic operations;
- behaves well in Ruby idiomatics;
- can suite as a value object;
- integrates enchantingly with other innumerous TimeMath abstractions.
Here it is:
Task: calculate 10:20 at first day of current month.
Implementations:
ActiveSupport (presumably, fix me if I’m wrong):
Time.now.beginning_of_month + 10.hours + 20.minutes
Cons: readable, but too many core extensions and method names to remember.
Plain atomic ops of TimeMath
:
TimeMath.min.advance(TimeMath.hour.advance(TimeMath.month.floor(Time.now), 10), 20)
Cons: the sense can be grasped, but–it is really un-Ruby-ish, to be honest.
TimeMath chainable operations:
TimeMath(Time.now).floor(:month).advance(:hour, 10).advance(:min, 20).call
Yes, this is still longer than ActiveSupport version, yet it is chainable, readable and introduces minimal new concepts (and no core extensions).
The more I’ve thought about this concept, the more I’ve liked it. And, at some point, I’ve understood this form is also pretty legitimate:
op = TimeMath().floor(:month).advance(:hour, 10).advance(:min, 20)
# => #<TimeMath::Op floor(:month).advance(:hour, 10).advance(:min, 20)>
# What now?.. Whatever!
op.call(Time.now)
# or
op.call(Time.parse('2016-06-01'), Time.parse('2016-05-01'), Time.parse('2016-04-01'))
# or -- we have applicable operation here! Isn't it a block?..
[Time.parse('2016-06-01'), Time.parse('2016-05-01'), Time.parse('2016-04-01')].
map(&op)
Do you like it? I, for one, do. (OK, I’ve designed it, but that’s not the cause, I swear!)
This is legitimate value object, which, without any additional effort, could be serialized/desirealized with YAML (and, therefore, be an argument for Sidekiq job, for example). So, you can now with an ease pass concepts like this (“calculate 10:20 at first-of-month for any argument”) wherever you want.
But, there is more.
When you design a good abstraction you know it by the fact it seduces your other abstractions in most of the library. Look, how it goes:
TimeMath.day
.sequence(Time.parse('2016-06-01')..Time.parse('2016-06-08'))
.to_a
# => [2016-06-01 00:00:00 +0300, 2016-06-02 00:00:00 +0300, 2016-06-03 00:00:00 +0300, 2016-06-04 00:00:00 +0300, 2016-06-05 00:00:00 +0300, 2016-06-06 00:00:00 +0300, 2016-06-07 00:00:00 +0300, 2016-06-08 00:00:00 +0300]
# ...and...
# why not?
TimeMath.day
.sequence(Time.parse('2016-06-01')..Time.parse('2016-06-08'))
.advance(:hour, 10).to_a
# => [2016-06-01 10:00:00 +0300, 2016-06-02 10:00:00 +0300, 2016-06-03 10:00:00 +0300, 2016-06-04 10:00:00 +0300, 2016-06-05 10:00:00 +0300, 2016-06-06 10:00:00 +0300, 2016-06-07 10:00:00 +0300, 2016-06-08 10:00:00 +0300]
Neat, huh?..
So, to the meat. TimeMath 0.0.5 adds the aforementioned operation object (including addition of it to sequences) and abandons any attempt to core_ext’ing Ruby classes (even opt-in ones).
It also adds:
- support for
Date
, previously shamefully absent; - float/rational advances, yay for the
hour.advance(Time.now, 1/3r)
; - arbitrary float/ceil, enjoy
hour.floor(Time.now, 3)
(floor to 3-hour mark), floats/rationals work too; - better syntax/semantics for time sequences creation (thanks Kenn Ejima for pointing at problem);
- experimental (yet already pretty cool) time arrays resampling;
- hell of cleanup and polishing.
Stay tuned! I feel like I already know how to solve entire crontab
case with only 2 more core methods in the gem ;)