Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
April 17, 2023 08:29 pm GMT

Be careful with time durations in Rails

This article was originally written by Jonathan Miles on the Honeybadger Developer Blog.

Rails has been described as a dialect of Ruby. If youve ever tried to program in pure Ruby after spending most of your time in Rails, youll understand why. Rails provides a lot of useful methods in core Ruby classes; for example, in Rails, nil allows you to call .present?, .blank?, .to_i, .to_a, and many more methods, all of which would raise an exception in a pure Ruby file.

Rails does this by "monkey patching" some of Ruby's classes, which refers to adding methods to a class that are not in the original class definition. This is a powerful feature of Ruby and is what allows Rails to create its unique 'dialect'. One of the most well known of these extensions is the date/time helpers that let you write code like 3.days.ago.

Date manipulation is a tricky part of programming, and these helpers are pretty smart, but they can get you in trouble if you're not aware of what they are doing. Take a look at these two lines of code:

Time.current + 1.month + 1.dayTime.current + 1.day + 1.month

Do these lines produce the same results? It depends when you run them. To understand why, we need to understand a bit about how these methods work so that we can use them effectively.

Unix time

In ye olden days of computers, programmers needed a way to manage Datetimes with some degree of accuracy. This lead to what became known as Unix time: an integer value holding the number of seconds since midnight January 1st 1970.

This is still used as the underlying way to store Datetimes in many systems.

Ruby lets us convert dates to these values:

> Time.new=> 2022-08-20 11:36:26.785387828 +1200> Time.new.to_i=> 1660952189 # Unix time

If we want to add an amount of time, then we just need to work out how many seconds we need. Therefore, we could add one days worth of seconds to change the time to tomorrow:

> time = Time.new=> 2022-08-20 11:40:27 +1200> time + 86_400=> 2022-08-21 11:40:27 +1200> time + (24 * 60 * 60) # more verbose version=> 2022-08-21 11:40:27 +1200

The .day and .days ActiveSupport helpers

Instead of developers across the world implementing their own days-to-seconds methods, ActiveSupport helpfully gives us one out-of-the-box in the form of .day and .days methods on Numerics:

> 1.day=> 1 day> 1.day.to_i=> 86400> (2.7).days.to_i # works with all Numeric types, not just integers=> 233280> (2.7 * 24 * 60 * 60).round #equivalent calculation to 2.7 days=> 233280

ActiveSupport::Duration

Although the value returned by the .day helper does a good job of imitating an integer, its not an integer. When you call .day or any of the other calendar-related helpers on Numerics, what you get back is an ActiveSupport::Duration. If we look at some of the other helpers, we can see why this is the case; well choose .month here.

First, unlike day, we cant have .month just return a fixed integer; because the months have different durations, it could be anywhere from 28 to 31 days. Lets start with January 30th and add a month to it:

> time = Time.new(2022, 1, 30)=> 2022-01-30 00:00:00 +1300> time + 1.month=> 2022-02-28 00:00:00 +1300

Here we see that the value has been capped to Feb 28th. In most cases, this is probably what we want so that February isnt skipped. However, this also creates a counter-intuitive situation that could cause problems in a codebase:

Time.new + 1.month + 1.dayTime.new + 1.day + 1.month

These lines look like they should give the same result. However, because of this capping behavior, they may not, depending on the time of year these commands are run. Indeed, our CI had failures due to this discrepancy that only showed up in late January.

time = Time.new(2022, 1, 30)=> 2022-01-30 00:00:00 +1300> time + 1.month + 1.day=> 2022-03-01 00:00:00 +1300> time + 1.day + 1.month=> 2022-02-28 00:00:00 +1300> time + (1.day + 1.month) # even adding brackets doesn't change it=> 2022-02-28 00:00:00 +1300> time + (1.month + 1.day)=> 2022-03-01 00:00:00 +1300

The order of operations here will determine which result is returned, and even adding brackets doesnt help. Whats going on inside ActiveSupport::Duration? How does it know February only has 28 days? Lets dive in and take a look.

ActiveSupport::Duration source

Looking at the source for Duration, well start with the method for addition, as it should give us some clues to what is going on:

# Adds another Duration or a Numeric to this Duration. Numeric values# are treated as seconds.def +(other)  if Duration === other    parts = @parts.merge(other._parts) do |_key, value, other_value|      value + other_value    end    Duration.new(value + other.value, parts, @variable || other.variable?)  else    seconds = @parts.fetch(:seconds, 0) + other    Duration.new(value + other, @parts.merge(seconds: seconds), @variable)  endend

What is interesting to me here is this @parts variable. It seems that a Duration stores the value in two ways: as the number of seconds and as a parts hash. While some of these are private to the class, fortunately for us, Ruby gives us some tools, such as #instance_variable_get, to see the values being stored here:

> duration = (1.year + 5.months + 1.month + 3.days)=> 1 year, 6 months, and 3 days> duration.instance_variable_get :@parts=> {:years=>1, :months=>6, :days=>3}> duration.instance_variable_get :@value=> 47594628

Therefore, Duration has more granularity that just X-number-of-seconds. Lets see what happens when it is added to a Time.

Time

Looking into the source of Rails Time calculations, we see that + is actually aliased to this method:

def plus_with_duration(other) # :nodoc:  if ActiveSupport::Duration === other    other.since(self)  else    plus_without_duration(other)  endendalias_method :plus_without_duration, :+alias_method :+, :plus_with_duration

Were only concerned with Duration right now, so it looks like our next stop is Duration#since:

def since(time = ::Time.current)  sum(1, time)end

Checking sum in the same class, we find:

def sum(sign, time = ::Time.current)  unless time.acts_like?(:time) || time.acts_like?(:date)    raise ::ArgumentError, "expected a time or date, got #{time.inspect}"  end  if @parts.empty?    time.since(sign * value)  else    @parts.inject(time) do |t, (type, number)|      if type == :seconds        t.since(sign * number)      elsif type == :minutes        t.since(sign * number * 60)      elsif type == :hours        t.since(sign * number * 3600)      else        t.advance(type => sign * number)      end    end  endend

Now were getting somewhere. It seems that for seconds, minutes, and hours, Rails just adds the raw number of seconds to the Time. This makes sense because these values will always be the same regardless of when the code is called. For month and year, though, it uses Time#advance. Looking up this method gives us the following:

# Provides precise Date calculations for years, months, and days. The +options+ parameter takes a hash with# any of these keys: <tt>:years</tt>, <tt>:months</tt>, <tt>:weeks</tt>, <tt>:days</tt>.def advance(options)  d = self  d = d >> options[:years] * 12 if options[:years]  d = d >> options[:months] if options[:months]  d = d + options[:weeks] * 7 if options[:weeks]  d = d + options[:days] if options[:days]  dend

Here, at last, we have our answer. >> and + are Rubys native Date methods. >> increments the month, while + increments the day. The Ruby docs for >> state that When the same day does not exist for the corresponding month, the last day of the month is used instead.

Conclusion

Rails date and time helpers are great. They save us from duplicating simple add-duration-to-time logic across our applications and make the code more readable. However, complex date manipulations are dangerous places full of edge-cases (and I didnt even mention time zones in this article).

So, what is a Rails developer to do? Well, based on what Ive learned here, this is my personal rule of thumb: use Rails helpers for single values (Time.current + 3.days, etc.) or simple ranges (1.month...3.months), but for anything more complicated, particularly calculations that involve mixing units (1.month + 3.days), it is better to use the Date#advance method (time.advance(months: 1, days: 3)). This sacrifices a little on readability but ensures the result is consistent. It also helps to highlight the fact that there is more than just simple mathematical operations going on, hopefully so other developers are more mindful of the way days and months will be treated by this code.


Original Link: https://dev.to/honeybadger/be-careful-with-time-durations-in-rails-3h9e

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To