简体   繁体   中英

Elegant way of safely changing the day of ruby Date?

I have to create a list of 24 months with the same day amongst them, properly handling the months that do not have day 29, 30 or 31.

What I currently do is:

def dates_list(first_month, assigned_day)
 (0...24).map do |period|
    begin
      (first_month + period.months).change(day: assigned_day)
    rescue ArgumentError
      (first_month + period.months).end_of_month
    end
  end
end

I need to rescue from ArgumentError as some cases raise it:

Date.parse('10-Feb-2019').change(day: 30)
# => ArgumentError: invalid date

I am looking for a safe and elegant solution that might already exist in ruby or rails. Something like:

Date.parse('10-Feb-2019').safe_change(day: 30) # => 28-Feb-2019

So I can write:

def dates_list(first_month, assigned_day)
  (0...24).map do |period|
    (first_month + period.months).safe_change(day: assigned_day)
  end
end

Does that exist or I would need to monkey patch Date ?

Workarounds (like a method that already creates this list) are very welcome.

UPDATE

The discussion about what to do with negative and 0 days made me realize this function is trying to guess the user's intent. And it also hard codes how many months to generate, and to generate by month.

This got me thinking what is this method doing? It's generates a list of advancing months, of a fixed size, and modifying them in a fixed way, and guessing what the user wants. If your function description includes " and " you probably need multiple functions. We separate generating the list of dates from modifying the list. We replace the hard coded parts with parameters. And instead of guessing what the user wants, we let them tell us with a block.

def date_generator(from, by:, how_many:)
  (0...how_many).map do |period|
    date = from + period.send(by)
    yield date
  end
end

The user can be very explicit about what they want to change. No surprises for the user nor the reader.

p date_generator(Date.parse('2019-02-01'), by: :month, how_many: 24) { |month|
  month.change(day: month.end_of_month.day)
}

We can take this a step further by turning it into an Enumerator . Then you can have as many as you like and do whatever you like with them using normal Enumerable methods ..

INFINITY = 1.0/0.0
def date_iterator(from, by:)
  Enumerator.new do |block|
    (0..INFINITY).each do |period|
      date = from + period.send(by)
      block << date
    end
  end
end

p date_iterator(Date.parse('2019-02-01'), by: :month)
    .take(24).map { |date|
      date.change(day: date.end_of_month.day)
    }

Now you can generate any list of dates, iterating by any field, of any length, with any changes. Rather than being hidden in a method, what's happening is very explicit to the reader. And if you have a special, common case you an wrap this in a method.

And the final step would be to make it a Date method.

class Date
  INFINITY = 1.0/0.0
  def iterator(by:)
    Enumerator.new do |block|
      (0..INFINITY).each do |period|
        date = self + period.send(by)
        block << date
      end
    end
  end
end

Date.parse('2019-02-01')
  .iterator(by: :month)
  .take(24).map { |date|
    date.change(day: date.end_of_month.day)
  }

And if you have a special, common case, you can write a special case function for it, give it a descriptive name, and document its special behaviors.

def next_two_years_of_months(date, day:)
  if day <= 0
    raise ArgumentError, "The day must be positive"
  end

  date.iterator(by: :month)
    .take(24)
    .map { |next_date|
      next_date.change(day: [day, next_date.end_of_month.day].min)
    }
end

PREVIOUS ANSWER

My first refactoring would be to remove the redundant code.

require 'date'

def dates_list(first_month, assigned_day)
 (0...24).map do |period|
   next_month = first_month + period.months
   begin
     next_month.change(day: assigned_day)
   rescue ArgumentError
     next_month.end_of_month
   end
 end
end

At this point, imo, the function is fine. It's clear what's happening. But you can take it a step further.

def dates_list(first_month, assigned_day)
  (0...24).map do |period|
    next_month = first_month + period.months
    day = [assigned_day, next_month.end_of_month.day].min
    next_month.change(day: day)
  end
end

I think that's marginally better. It makes the decision a little more explicit and doesn't paper over other possible argument errors.

If you find yourself doing this a lot, you could add it as a Date method.

class Date
  def change_day(day)
    change(day: [day, end_of_month.day].min)
  end
end

I'm not so hot on either change_day nor safe_change . Neither really says "this will use the day or if it's out of bounds the last day of the month" and I'm not sure how to express that.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM