Rails ActiveSupport extending to_date, to_datetime, to_time - ruby-on-rails

The application I'm working with insists on displaying all user-input dates (via jquery datepicker) in the non standard American %m/%d/%Y format. As a result we have a lot of strptime methods scattered throughout our controllers.
I'm trying to clean it up and would like to overload the Rails to_date, to_datetime, and to_time extensions so these are no longer necessary.
#config/initializers/string.rb
class String
def to_date
begin
Date.strptime(self, '%m/%d/%Y') #attempt to parse in american format
rescue ArgumentError
Date.parse(self, false) unless blank? #if an error, execute original Rails to_date
#(pulled from Rails source)
end
end
def to_datetime
begin
DateTime.strptime(self,'%m/%d/%Y')
rescue ArgumentError
DateTime.parse(self, false) unless blank?
end
end
def to_time(form = :local)
begin
Time.strptime(self,'%m/%d/%Y')
rescue ArgumentError
parts = Date._parse(self, false)
return if parts.empty?
now = Time.now
time = Time.new(
parts.fetch(:year, now.year),
parts.fetch(:mon, now.month),
parts.fetch(:mday, now.day),
parts.fetch(:hour, 0),
parts.fetch(:min, 0),
parts.fetch(:sec, 0) + parts.fetch(:sec_fraction, 0),
parts.fetch(:offset, form == :utc ? 0 : nil)
)
form == :utc ? time.utc : time.getlocal
end
end
end
Anyway, this works great in rails console; "06/24/2014".to_date and variants behave exactly as I would like them. However, it looks like ActiveRecord doesn't use these overloaded definitions when creating/validating new table entries, eg
MyModelName.create(start_date:"06/07/2014") gives a start date of 2014-07-06.
What can I do to make ActiveRecord recognize these overloaded definitions?

You can set default time and date formats in config/application.rb file this way:
my_date_formats = { :default => '%d.%m.%Y' }
Time::DATE_FORMATS.merge!(my_date_formats)
Date::DATE_FORMATS.merge!(my_date_formats)

The ruby-american_date gem may be what you want. It forces Date/DateTime/Time.parse to parse American formatted dates. Simply include it in your project's Gemfile.

Related

Overriding an active support method with a ruby refinement

I'd like to use a Ruby Refinement to monkey patch an ActiveSupport method for outputting time in a specific format.
My ultimate goal is to have JSON.pretty_generate(active_record.as_json) print all timestamps in UTC, iso8601, 6 decimals. And I want to have all other timestamp printing behave normally.
This is what I have so far:
module ActiveSupportExtensions
refine ActiveSupport::TimeWithZone do
def to_s(_format = :default)
utc.iso8601(6)
end
end
end
class Export
using ActiveSupportExtensions
def export
puts JSON.pretty_generate(User.last.as_json(only: [:created_at]))
end
end
Export.new.export
Which outputs the following (not what I want).
{
"created_at": "2022-04-05 14:36:07 -0700"
}
What's interesting, is if I monkey patch this the regular way:
class ActiveSupport::TimeWithZone
def to_s
utc.iso8601(6)
end
end
puts JSON.pretty_generate(User.last.as_json(only: [:created_at]))
I get exactly what I want:
{
"created_at": "2022-04-05T21:36:07.878101Z"
}
The only issue is that this overrides the entire applications TimeWithZone class, which is not something I want to do for obvious reasons.
Thanks to Lam Phan comment, it's not possible via a refinement unfortunately.
However I was able to do it by override the default timestamp format.
# save previous default value
previous_default = ::Time::DATE_FORMATS[:default]
# set new default to be the utc timezone with iso8601 format
::Time::DATE_FORMATS[:default] = proc { |time| time.utc.iso8601(6) }
puts JSON.pretty_generate(User.last.as_json(only: [:created_at]))
# set the default back if we have one
if previous_default.blank?
::Time::DATE_FORMATS.delete(:default)
else
::Time::DATE_FORMATS[:default] = previous_default
end

ActiveRecord where method call optimisation

I have a piece of code witch looks like this:
Post.all.reject {|p| p.created_at.beginning_of_month != params[:date].to_date}
Is there a method to write the same code using where method and to not get all elements?
If you want to use where, I'd go by:
# x-month being a date from your desired month.
# .. defines the range between the beginning and the end
Post.where(:created_at => x-month.beginning_of_month..x-month.end_of_month)
AFAIK, there is no database-agnostic solution to this, because you need to extract the month from the date. So, in raw SQL you would have :
date = params[:date].to_date
Post.where("MONTH(created_at) != ? AND YEAR(created_at) = ?", [date.month, date.year])
Now it is possible to cheat a bit with normalization in order to use a db-agnostic solution.
Just add some created_at_month and created_at_year columns to your model, along with this callback :
after_create :denormalize_created_at
def denormalize_created_at
assign_attributes created_at_month: created_at.month,
created_at_year: created_at.year
save validate: false
end
Now you can do:
Rails < 4 :
date = params[:date].to_date
Post
.where(Post.arel_table[:created_at_month].not_eq date.month)
.where(created_at_year: date.year)
Rails 4+ :
date = params[:date].to_date
Post.not(created_at_month: date.month).where(created_at_year: date.year)
mysql has a MONTH function to get the month of a datetime column.
Post.where("MONTH(created_at) != ?", params[:date].to_date.month)

Formatting dates coming from params

In Grails, if I define a locale, and put a date on specific format on i18n file, like (dd/mm/AAAA), if call one request like:
http://myapp/myaction?object.date=10/12/2013
When I get print: params.date, it comes to me a date object.
How can I do the same on rails?
Normally the Rails handles this for you. For instance, the form helper datetime_select works in conjunction with some activerecord magic
to ensure ensure time/date types survive the round-trip. There are various alternatives to the standard date-pickers.
If this doesn't work for you e.g. rails isn't generating the forms, there are (at least) a couple of options.
One option, slightly evi, is to monkey-patch HashWithIndifferentAccess (used by request params) to do type conversions based on the key name. It could look something like:
module AddTypedKeys
def [](key)
key?(key) ? super : find_candidate(key.to_s)
end
private
# look for key with a type extension
def find_candidate(key)
keys.each do |k|
name, type = k.split('.', 2)
return typify_param(self[k], type) if name == key
end
nil
end
def typify_param(value, type)
case type
when 'date'
value.to_date rescue nil
else
value
end
end
end
HashWithIndifferentAccess.send(:include, AddTypedKeys)
This will extend params[] in the way you describe. To use it within rais, you can drop it into an initialiser, eg confg/initializers/typed_params.rb
To see it working, you can test with
params = HashWithIndifferentAccess.new({'a' => 'hello', 'b.date' => '10/1/2013', 'c.date' => 'bob'})
puts params['b.date'] # returns string
puts params['b'] # returns timestamp
puts params['a'] # returns string
puts params['c'] # nil (invalid date parsed)
However... I'm not sure it's worth the effort, and it will likely not work with Rails 4 / StrongParameters.
A better solution would be using virtual attributes in your models. See this SO post for a really good example using chronic.

rails i18n - How to handle case of a nil date being passed ie l(nil)

I am working with a lot of legacy data and occasionally a datetime field is nil/null. This breaks the localization. Is there a recommended way of fixing this aside from doing this:
dt = nil
l(dt) unless dt.nil?
I think there is a cleaner way to fix this. I monkey patched I18n in an initializer called relaxed_i18n.rb
This is the content of that file:
module I18n
class << self
alias_method :original_localize, :localize
def localize object, options = {}
object.present? ? original_localize(object, options) : ''
end
end
end
And this is the RSpec code I used to validate the output of this method:
require 'rails_helper'
describe 'I18n' do
it "doesn't crash and burn on nil" do
expect(I18n.localize(nil)).to eq ''
end
it 'returns a date with Dutch formatting' do
date = Date.new(2013, 5, 17)
expect(I18n.localize(date, format: '%d-%m-%Y')).to eq '17-05-2013'
end
end
To extend Larry K's answer,
The helper should include a hash to pass options to I18n.
def ldate(dt, hash = {})
dt ? l(dt, hash) : nil
end
This allows you to pass options like this:
= ldate #term.end_date, format: :short
Unfortunately, there is no built-in solution. See post.
You can define your own helper that supplies the "nil" human-readable value. Eg:
def ldate(dt)
dt ? l(dt) : t("[???]")
end
I recently updated an application that uses jankeesvw's relaxed i18n method to Ruby 3.1 and found an issue with missing arguments.
The related change in the I18n gem is this: ruby-i18n/i18n#5eeaad7.
Also this Ruby 3 change is related: https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/
Updated the code with this:
module I18n
class << self
alias original_localize localize
def localize(object, locale: nil, format: nil, **options)
object.present? ? original_localize(object, locale: locale, format: format, **options) : ''
end
end
end
And it works again!

how to convert a hash value returned from a date_select in rails to a Date object?

I have a date_select in my view inside a form, however on submit the value returned is in a hash form like so:
{"(1i)"=>"2010", "(2i)"=>"8", "(3i)"=>"16"}
how can i convert that in to a Date format in rails so i can use it as a condition when querying the database e.g :condition => {:dates == :some_date_from_date_select}? i tried calling Date.parse(:some_date_from_date_select) but it didn't work because its expecting a string and not a hash map.
is there a rails way to do this?
thanks
I don't know about a rails way, but this "one-liner" does the trick:
irb(main):036:0> d = Date.parse( {"(1i)"=>"2010", "(2i)"=>"8", "(3i)"=>"16"}.to_a.sort.collect{|c| c[1]}.join("-") )
=> #<Date: 4910849/2,0,2299161>
irb(main):037:0> d.to_s
=> "2010-08-16"
Or, with less magic:
h={"(1i)"=>"2010", "(2i)"=>"8", "(3i)"=>"16"}
d=Date.new(h['(1i)'].to_i, h['(2i)'].to_i, h['(3i)'].to_i)
d.to_s
=> "2010-08-16"
I have a short one line solution for this
params["due_date"] = {"date(3i)"=>"14", "date(2i)"=>"4", "date(1i)"=>"2014"}
params["due_date"].map{|k,v| v}.join("-").to_date
=> Mon, 14 Apr 2014
Here's a generic way to do it, which also supports partial dates/times and empty fields:
def date_from_date_select_fields(params, name)
parts = (1..6).map do |e|
params["#{name}(#{e}i)"].to_i
end
# remove trailing zeros
parts = parts.slice(0, parts.rindex{|e| e != 0}.to_i + 1)
return nil if parts[0] == 0 # empty date fields set
Date.new(*parts)
end
Example usage:
# example input:
#
# params = {
# "user":
# "date_of_birth(1i)": "2010",
# "date_of_birth(2i)": "8",
# "date_of_birth(3i)": "16"
# }
# }
date_of_birth = date_from_date_select_fields(params[:user], 'date_of_birth')
Date.new(*params["due_date"].values.map(&:to_i))
Note: Works in ruby 1.9.2+ since it depends on the order of the hash elements.
Two goodies here:
Symbol to Proc
Splat operator
This particular code (the one that does conversion) can be tracked from lib/active_record/connection_adapters/abstract/schema_definitions.rb, line no 67 onwards, i.e. the method type_cast.
These two methods are used to generate a date from string:
def fast_string_to_date(string)
if string =~ Format::ISO_DATE
new_date $1.to_i, $2.to_i, $3.to_i
end
end
# Doesn't handle time zones.
def fast_string_to_time(string)
if string =~ Format::ISO_DATETIME
microsec = ($7.to_f * 1_000_000).to_i
new_time $1.to_i, $2.to_i, $3.to_i, $4.to_i, $5.to_i, $6.to_i, microsec
end
end
# Note that ISO_DATE is:
ISO_DATE = /\A(\d{4})-(\d\d)-(\d\d)\z/

Resources