Overriding an active support method with a ruby refinement - ruby-on-rails

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

Related

Ruby Default Arguments: Static or Dynamic?

Are default arguments in ruby methods static? This matters when default arguments are meant to be a dynamic date, such as Date.today. Consider the following example in a rails application:
class User < ApplicationRecord
def eligible_to_vote?(date_of_interest = Date.today)
p date_of_interest
end
end
Will the default date_of_interest date always be the the same, static date from when I started the rails server?
Or: will it always dynamically grab "today's date" within the context of the date that the eligible_to_vote? method was called?
I know the following would for sure dynamically grab Date.today:
# Always dynamically sets `date` variable when no argument passed in
class User < ApplicationRecord
def eligible_to_vote?(date_of_interest = nil)
date = date_of_interest.present? ? date_of_interest : Date.today
p date_of_interest
end
end
What I'm mostly interested in is if default method arguments are dynamically generated or not. Whatever the answer is, it would be nice to have some official reference to answer this question as well. Thanks!
What I'm mostly interested in is if default method arguments are dynamically generated or not.
It would be trivially easy to test this:
def are_default_arguments_evaluated_every_time?(optional_parameter = puts('YES!')) end
are_default_arguments_evaluated_every_time?
# YES!
are_default_arguments_evaluated_every_time?
# YES!
If default arguments were evaluated at method definition time, this would print YES! only once, and before calling the method. If default arguments were evaluated only on the first call and then cached, this would print YES! only once, when the method first gets called.
Whatever the answer is, it would be nice to have some official reference to answer this question as well.
This is specified in section §13.3.3 Method invocation, step h), sub-step 7), sub-sub-step i) of the ISO Ruby Language Specification.
It is dynamic because Ruby is interpreted, not compiled language.
✎ require 'date'
✎ def test param = DateTime.now
puts param
end
✎ 3.times { test; sleep(1) }
2018-12-14T18:10:08+01:00
2018-12-14T18:10:09+01:00
2018-12-14T18:10:10+01:00
it's not static, Date.today is evaluated every time.
Take for example:
def foo(some_var = boo)
p some_var
end
def boo
p "this is boo"
Date.today
end
When you run today
foo
# "this is boo"
# Fri, 14 Dec 2018
foo
# "this is boo"
# Fri, 14 Dec 2018
When you run tomorrow
foo
# "this is boo"
# Fri, 15 Dec 2018

How can I validate and parse an ISO 8601 timestamp with user's time zone?

I need to be able to receive a user-input timestamp, with an optional time zone component, validate that is it a valid ISO 8601 time representation, and parse it according to the user's configured time zone.
I'm using Rails 4.2.6 on Ruby 2.3. I had hoped that Time.zone (ActiveSupport::TimeZone) would have an equivalent implementation to Time::iso8601 so that I could rescue ArgumentError exceptions to determine if the user input was a valid ISO 8601 time representation. Then I could do something like:
user_time_zone = 'America/Los_Angeles' # Would actually be from user's stored settings
params = {when: '2016-04-01T01:01:01'} # Would actually be from user input
# Would actually use Time::use_zone in around_action filter
Time.use_zone(user_time_zone) do
timestamp = Time.zone.iso8601 params[:when]
end
But, alas, no such method exists. And I can't find an equivalent one.
I can't use Time.zone.parse, because it treats ambiguous dates as valid (e.g. Time.zone.parse '04/11/16' # => Tue, 16 Nov 0004 00:00:00 LMT -07:52).
The best alternative I've been able to come up with so far is:
Time.use_zone(user_time_zone) do
old_tz = ENV['TZ']
ENV['TZ'] = Time.zone.name
timestamp = Time.iso8601 params[:when] # => 2016-04-01 01:01:01 -0700
ENV['TZ'] = old_tz
end
But this is ugly, messing around with an environment variable this way doesn't feel proper, and it and certainly isn't Rails-like. How can I validate and parse the time according to the user's time zone in a Rails way?
I suggest that you simply split the assignment into two steps: validate the ISO8601 format first and if valid, parse it:
user_time_zone = 'America/Los_Angeles'
params = { when: '2016-04-01T01:01:01' }
begin
Time.iso8601(params[:when]) # raises ArgumentError if format invalid
rescue ArgumentError => e
puts "invalid time format"
return
end
Time.use_zone(user_time_zone) do
timestamp = Time.zone.parse(params[:when])
end
I think you can still use an around_action for your use case. That's what I use and it works well for me.
In my ApplicationController I have:
class ApplicationController < ActionController::Base
around_action :set_time_zone
def set_time_zone
old_time_zone = Time.zone
Time.zone = user_time_zone
yield
ensure
Time.zone = old_time_zone
end
end
Any calls to Time will use the user's time zone within the scope of the request.

Trouble comparing time with RSpec

I am using Ruby on Rails 4 and the rspec-rails gem 2.14. For a my object I would like to compare the current time with the updated_at object attribute after a controller action run, but I am in trouble since the spec does not pass. That is, given the following is the spec code:
it "updates updated_at attribute" do
Timecop.freeze
patch :update
#article.reload
expect(#article.updated_at).to eq(Time.now)
end
When I run the above spec I get the following error:
Failure/Error: expect(#article.updated_at).to eq(Time.now)
expected: 2013-12-05 14:42:20 UTC
got: Thu, 05 Dec 2013 08:42:20 CST -06:00
(compared using ==)
How can I make the spec to pass?
Note: I tried also the following (note the utc addition):
it "updates updated_at attribute" do
Timecop.freeze
patch :update
#article.reload
expect(#article.updated_at.utc).to eq(Time.now)
end
but the spec still does not pass (note the "got" value difference):
Failure/Error: expect(#article.updated_at.utc).to eq(Time.now)
expected: 2013-12-05 14:42:20 UTC
got: 2013-12-05 14:42:20 UTC
(compared using ==)
I find using the be_within default rspec matcher more elegant:
expect(#article.updated_at.utc).to be_within(1.second).of Time.now
Ruby Time object maintains greater precision than the database does. When the value is read back from the database, it’s only preserved to microsecond precision, while the in-memory representation is precise to nanoseconds.
If you don't care about millisecond difference, you could do a to_s/to_i on both sides of your expectation
expect(#article.updated_at.utc.to_s).to eq(Time.now.to_s)
or
expect(#article.updated_at.utc.to_i).to eq(Time.now.to_i)
Refer to this for more information about why the times are different
Old post, but I hope it helps anyone who enters here for a solution. I think it's easier and more reliable to just create the date manually:
it "updates updated_at attribute" do
freezed_time = Time.utc(2015, 1, 1, 12, 0, 0) #Put here any time you want
Timecop.freeze(freezed_time) do
patch :update
#article.reload
expect(#article.updated_at).to eq(freezed_time)
end
end
This ensures the stored date is the right one, without doing to_x or worrying about decimals.
yep as Oin is suggesting be_within matcher is the best practice
...and it has some more uscases -> http://www.eq8.eu/blogs/27-rspec-be_within-matcher
But one more way how to deal with this is to use Rails built in midday and middnight attributes.
it do
# ...
stubtime = Time.now.midday
expect(Time).to receive(:now).and_return(stubtime)
patch :update
expect(#article.reload.updated_at).to eq(stubtime)
# ...
end
Now this is just for demonstration !
I wouldn't use this in a controller as you are stubbing all Time.new calls => all time attributes will have same time => may not prove concept you are trying to achive. I usually use it in composed Ruby Objects similar to this:
class MyService
attr_reader :time_evaluator, resource
def initialize(resource:, time_evaluator: ->{Time.now})
#time_evaluator = time_evaluator
#resource = resource
end
def call
# do some complex logic
resource.published_at = time_evaluator.call
end
end
require 'rspec'
require 'active_support/time'
require 'ostruct'
RSpec.describe MyService do
let(:service) { described_class.new(resource: resource, time_evaluator: -> { Time.now.midday } ) }
let(:resource) { OpenStruct.new }
it do
service.call
expect(resource.published_at).to eq(Time.now.midday)
end
end
But honestly I recommend to stick with be_within matcher even when comparing Time.now.midday !
So yes pls stick with be_within matcher ;)
update 2017-02
Question in comment:
what if the times are in a Hash? any way to make expect(hash_1).to eq(hash_2) work when some hash_1 values are pre-db-times and the corresponding values in hash_2 are post-db-times? –
expect({mytime: Time.now}).to match({mytime: be_within(3.seconds).of(Time.now)}) `
you can pass any RSpec matcher to the match matcher
(so e.g. you can even do API testing with pure RSpec)
As for "post-db-times" I guess you mean string that is generated after saving to DB. I would suggest decouple this case to 2 expectations (one ensuring hash structure, second checking the time) So you can do something like:
hash = {mytime: Time.now.to_s(:db)}
expect(hash).to match({mytime: be_kind_of(String))
expect(Time.parse(hash.fetch(:mytime))).to be_within(3.seconds).of(Time.now)
But if this case is too often in your test suite I would suggest writing your own RSpec matcher (e.g. be_near_time_now_db_string) converting db string time to Time object and then use this as a part of the match(hash) :
expect(hash).to match({mytime: be_near_time_now_db_string}) # you need to write your own matcher for this to work.
The easiest way I found around this problem is to create a current_time test helper method like so:
module SpecHelpers
# Database time rounds to the nearest millisecond, so for comparison its
# easiest to use this method instead
def current_time
Time.zone.now.change(usec: 0)
end
end
RSpec.configure do |config|
config.include SpecHelpers
end
Now the time is always rounded to the nearest millisecond to comparisons are straightforward:
it "updates updated_at attribute" do
Timecop.freeze(current_time)
patch :update
#article.reload
expect(#article.updated_at).to eq(current_time)
end
You can convert the date/datetime/time object to a string as it's stored in the database with to_s(:db).
expect(#article.updated_at.to_s(:db)).to eq '2015-01-01 00:00:00'
expect(#article.updated_at.to_s(:db)).to eq Time.current.to_s(:db)
Because I was comparing hashes, most of these solutions did not work for me so I found the easiest solution was to simply grab the data from the hash I was comparing. Since the updated_at times are not actually useful for me to test this works fine.
data = { updated_at: Date.new(2019, 1, 1,), some_other_keys: ...}
expect(data).to eq(
{updated_at: data[:updated_at], some_other_keys: ...}
)
In Rails 4.1+ you can use Time Helpers:
include ActiveSupport::Testing::TimeHelpers
describe "some test" do
around { |example| freeze_time { example.run } }
it "updates updated_at attribute" do
expect { patch :update }.to change { #article.reload.updated_at }.to(Time.current)
end
end

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 ActiveRecord date parsing for i18n (specifically european date formats)

I'm working on a rails project for an Australian website. As a result, they want to be able to enter date formats in the more european-standard of 'dd/mm/yyyy' rather than the US-centric 'mm/dd/yyyy'. I have an ActiveRecord model with a Date field. I'm using jQuery's datepicker to provide the date select on a text field, and have it setting the date to a proper format. But, when I try to save the record, it gets the date wrong. Even when I've set the custom date formats in an intializer according to the i18n guide.
>> b = BlogPost.new
>> b.posted_on = '20/07/2010'
=> "20/07/2010"
>> b.posted_on
=> nil
>> b.posted_on = '07/20/2010'
=> Tue, 20 Jul 2010
It seems that Rails is just using Date.parse to convert the string into a Date object. Is there any way to fix this for the whole project? I don't want to have to write custom code for each model.
class Date
class << self
def _parse_with_us_format(date, *args)
if date =~ %r{^(\d+)/(\d+)/(\d+)$}
_parse_without_us_format("#{$3.length == 2 ? "20#{$3}" : $3}-#{$1}-#{$2}", *args)
else
_parse_without_us_format(date, *args)
end
end
alias_method_chain :_parse, :us_format
end
end
Try to change the default date format (in config/environment.rb)
ActiveSupport::CoreExtensions::Time::Conversions::DATE_FORMATS.
merge!(default => '%d/%m/%Y %H:%M')
Find out more here http://blog.nominet.org.uk/tech/2007/06/14/date-and-time-formating-issues-in-ruby-on-rails/

Resources