-
Notifications
You must be signed in to change notification settings - Fork 21.9k
Ensure input to distance_of_time_in_words is not nil #20701
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Perhaps converting input to Numeric before any of the comparisons to avoid the mixed type case. Would that be weird? So assuming before this line there was something like: from_time, to_time = from_time.to_f, to_time.to_f You could do things like this, where it would normally be a comparison error: irb> helper.distance_of_time_in_words(Time.now)
=> "over 45 years"
irb> helper.distance_of_time_in_words(0, Time.now)
=> "over 45 years"
irb> helper.distance_of_time_in_words(Time.now, 0)
=> "over 45 years" Is that sane? |
@sgrif is there anything I can do to improve this so that it gets some attention? |
Rails gets quite a lot of issues and pull requests. Give it a bit of time, people will catch up on the issue. Thank you so much for the work! |
Maybe changing: from_time = from_time.to_time if from_time.respond_to?(:to_time)
to_time = to_time.to_time if to_time.respond_to?(:to_time) to: if from_time.respond_to?(:to_time)
from_time = from_time.to_time
else
from_time = from_time.to_f
end
if to_time.respond_to?(:to_time)
to_time = to_time.to_time
else
to_time = to_time.to_f
end |
@gsamokovarov thanks for keeping me accountable! I can't imagine how overwhelming it must be to see so much activity on a project 😳 @rafaelfranca I believe that's still problematic, because the caller could pass one argument that is convertible Fundamentally I think the problem is to do with the method signature being so flexible (accepting both time and numeric values). I wonder if it'd be sane to convert all arguments to a common type (e.g. float) to deal with them uniformly inside the method? |
options = { | ||
scope: :'datetime.distance_in_words' | ||
}.merge!(options) | ||
|
||
from_time = from_time.to_time if from_time.respond_to?(:to_time) | ||
to_time = to_time.to_time if to_time.respond_to?(:to_time) | ||
from_time, to_time = Time.at(from_time), Time.at(to_time) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe this is effectively a conversion of input to Time. One thing to note is that it's not timezone aware. The suite seemed happy locally so maybe it's not a concern since this method deals in relative times? Truth is I don't trust myself with time, so I'd love a sanity check on this...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pixeltrix WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should be fine - the two lines above convert any AS::TimeWithZone
instances to local times so Time.at
just essentially copies the values to a new Time
instance. If there was a problem it's probably an underlying bug in to_time
.
Thinking about it, maybe it might make things simpler if we define Numeric#to_time
? e.g:
class Numeric
def to_time
Time.at(self)
end
end
This way we can eliminate the two Time.at
calls, add a check for whether the two arguments respond to to_time
and raise if they don't, eliminating the nil check.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By adding Numeric#to_time
you could then make the method setup something like this:
def distance_of_time_in_words(from, to = 0, options = {})
unless from.respond_to?(:to_time)
raise ArgumentError, "from must be a time-like value"
end
unless to.respond_to?(:to_time)
raise ArgumentError, "to must be a time-like value"
end
from_time, to_time = [from.to_time, to.to_time].sort
distance_in_minutes = ((to_time - from_time)/60.0).round
distance_in_seconds = (to_time - from_time).round
the exception messages could be better so feel free to change them
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very interesting idea! Would there be any concern that the Time conversion of Numeric values is always in local time? Is that confusing*?
* time is always so confusing 😞
2627419
to
f1d3257
Compare
@@ -5,6 +5,11 @@ | |||
require 'active_support/core_ext/date/acts_like' | |||
|
|||
class Numeric | |||
# Convert number to local Time. | |||
def to_time | |||
Time.at(self) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there any concern that Numeric conversions to Time are always left in local (system) time?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pixeltrix I think I feel like the numeric -> time conversion is too special-case to warrant making all numerics respond to to_time
, even though it's still an explicit conversion. 😕
I guess my contention is that the choice of time unit & epoch are both domain concerns of Time
, so it feels a stretch to leak them to Numeric
. (Which I think might be more obvious once we expand this method's documentation to a workable level of detail.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I was afraid of this. Shall I throw away those commits and stick with the implementation up to ec2e09b?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😂 it's settled then!
2a8be0a
to
67f57c3
Compare
else | ||
minutes_with_offset = distance_in_minutes | ||
end | ||
fyear = from_time.year |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we be a bit more explicit with these? It took me a minute to figure out what fyear
meant.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done! :)
55b9efa
to
8b06c70
Compare
8b06c70
to
db19c93
Compare
db19c93
to
4c44b35
Compare
4c44b35
to
9b1de29
Compare
I run into this issue every time I'm teaching our Ruby course. I was reminded this week that the PR is still open. Any chance it can get some attention? Thanks! ❤️ |
b0fd030
to
1cb1def
Compare
1cb1def
to
ed689eb
Compare
On a wave of inspiration from RailsConf, I got this PR freshly rebased! Would love to talk about getting it merged. Anyone want to hash it out in person? 💫 |
Discussed with @iamvery at RailsConf that implement |
@pixeltrix updated! |
There's a failure for Ruby 2.2.7, but it doesn't appear to be related? |
from_time = from_time.to_time if from_time.respond_to?(:to_time) | ||
to_time = to_time.to_time if to_time.respond_to?(:to_time) | ||
from_time = from_time.to_time | ||
to_time = to_time.to_time |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like we could move this up to the start of the method and combine into if
statements:
if from_time.is_a?(Numeric)
from_time = Time.at(from_time)
elsif from_time.respond_to?(:to_time)
from_time = from_time.to_time
else
raise ArgumentError, "first argument must either be a time, date or numeric, was '#{from_time.inspect}'"
end
if to_time.is_a?(Numeric)
to_time = Time.at(to_time)
elsif from_time.respond_to?(:to_time)
to_time = to_time.to_time
else
raise ArgumentError, "second argument must either be a time, date or numeric, was '#{to_time.inspect}'"
end
to_time = to_time.to_time | ||
else | ||
raise ArgumentError, "second argument must be convertible to_time" | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd love to extract a small, private interface to perform this logic. Something like:
def normalize_to_time(value)
if value.is_a?(Numeric)
Time.at(value)
elsif value.responds_to?(:to_time)
value.to_time
else
yield(value) if block_given?
end
end
Then we could replace these two similar blocks of code with something like:
from_time = normalize_to_time(from_time) { raise ArgumentError, "first argument must be convertible_to_time" }
to_time = normalize_to_time(to_time) { raise ArgumentError, "second argument must be convertible_to_time" }
The only reason that I didn't do that here is I'm not completely certain how Rails uses this module. If I put that in a private method, would it be accessible in the places where this module is included?
Anyway, that feels like it'd be a valuable change, but in the end I decided it might be better to discuss than me stumbling around trying to figure it out.
Thanks for the feedback in helping get this in!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wasn't absolutely certain, so I was able to confirm that methods included from a module have access to private interfaces (I suppose they're also included?)
module Lol
def wat
haha
end
private
def haha
"OK"
end
end
class Giggle
include Lol
end
Giggle.new
=> #<Giggle:0x007ff69e0a1738>
Giggle.new.wat
=> "OK"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@iamvery yes, that seems fine - I think the method name needs to be more specific to distance_of_time_in_words
though as normalize_to_time
seems quite generic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Once you've fixed the two items it'll be good to merge so can you squash your commits, thanks 👍
@@ -91,12 +91,13 @@ module DateHelper | |||
# distance_of_time_in_words(from_time, from_time + 50.minutes, scope: 'datetime.distance_in_words.short') # => "an hour" | |||
# distance_of_time_in_words(from_time, from_time + 3.hours, scope: 'datetime.distance_in_words.short') # => "3 hours" | |||
def distance_of_time_in_words(from_time, to_time = 0, options = {}) | |||
from_time = normalize_distance_of_time_argument_to_time(from_time) | |||
to_time = normalize_distance_of_time_argument_to_time(to_time) | |||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you move these lines down underneath the options
@@ -687,6 +684,16 @@ def time_tag(date_or_time, *args, &block) | |||
|
|||
content_tag("time".freeze, content, options.reverse_merge(datetime: datetime), &block) | |||
end | |||
|
|||
def normalize_distance_of_time_argument_to_time(value) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This needs to be private so you'll need to add private
and then indent again or rubocop will complain
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤦♂️
18a3a60
to
afc3c25
Compare
@@ -95,8 +95,8 @@ def distance_of_time_in_words(from_time, to_time = 0, options = {}) | |||
scope: :'datetime.distance_in_words' | |||
}.merge!(options) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason to mutate the new hash rather than doing options.merge(scope: :'datetime.distance_in_words')
? I don't particularly care, just curious.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably performance reasons
@pixeltrix updated! do you want/need me to squash anything? |
|
||
private | ||
|
||
def normalize_distance_of_time_argument_to_time(value) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you need to indent this otherwise code climate won't pass
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah hah, I'm not used to that style. Will fix and squash!
@iamvery yes, just the indentation fix and squash your commits please 👍 |
afc3c25
to
8241ffd
Compare
@pixeltrix rebased the history into a more cohesive story. Let me know if you need anything else. Thanks for sticking with me through this! ❤️ |
elsif value.respond_to?(:to_time) | ||
value.to_time | ||
else | ||
raise ArgumentError, "#{v} must be convertible to_time" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This needs to be raise ArgumentError, "#{value} must be convertible to_time"
- getting a NameError
when running the tests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😂 you'd think I'm tired or something
@iamvery can you squash to a single commit, please. For small changes like this it's cleaner that multiple commits if we need to revert, etc. and we can get the story you're telling from the PR. If we didn't do this it'd make navigating through git history much more painful when trying to do |
8241ffd
to
53148f3
Compare
@pixeltrix you have been tremendously patient and helpful. Thank you for all your work to make and keep Rails great. |
elsif value.respond_to?(:to_time) | ||
value.to_time | ||
else | ||
raise ArgumentError, "#{value} must be convertible to_time" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One last change, I promise 😄
Using #{value}
with a nil
value isn't helpful, e.g:
>> value = nil
>> "#{value} can't be converted to a Time value"
=> " can't be converted to a Time value"
It's better to use inspect
:
>> value = nil
>> "#{value.inspect} can't be converted to a Time value"
=> "nil can't be converted to a Time value"
Can you also tweak the message to what I've used in this comment which is more in line with other similar Ruby messages, thanks.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No worries at all! I'm honored to have gotten so much great feedback this week 💫 Updated 👊
* Internally all input is converted to time so that it can be treated uniformly. Remove now-unneeded condition * Now that all input is treated is converted to time, we no longer need to type check it. Rename variables to clarify their purpose Extract private method to normalize distance_of_time args to time Update actionview changelog
53148f3
to
756de66
Compare
@iamvery thanks for helping make Rails better 👍 |
👏 and you |
- Remove ``` * Namespace error pages' CSS selectors to stop the styles from bleeding into other pages when using Turbolinks. ([Pull Request](rails#28814)) ``` since it was backported to `5-1-stable` by 50d5baf and `5-0-stable` by d1c4a39. - Remove ``` * Allow irb options to be passed from `rails console` command. ([Pull Request](rails#29010)) ``` since it was backported to `5-1-stable` by e91b483. - Remove ``` * Load environment file in `dbconsole` command. ([Pull Request](rails#29725)) ``` since it was backported to `5-1-stable` by 7f93428. - Remove ``` * Gemfile for new apps: upgrade redis-rb from ~> 3.0 to 4.0. ([Pull Request](rails#30748)) ``` since it was backported to `5-1-stable` by 3789531. - Remove ``` * Fix minitest rails plugin. The custom reporters are added only if needed. This will fix conflicts with others plugins. ([Commit](rails@ac99916)) ``` since it was backported to `5-1-stable` by caa7695. - Remove ``` * Add support for compatibility with redis-rb gem for 4.0 version. ([Pull Request](rails#30748)) ``` since it was backported to `5-1-stable` by 3789531. - Remove ``` * Add `action_controller_api` and `action_controller_base` load hooks to be called in `ActiveSupport.on_load`. ([Pull Request](rails#28402)) ``` since it was backported to `5-1-stable` by b9a5fd7. - Remove ``` * `driven_by` now registers poltergeist and capybara-webkit. ([Pull Request](rails#29315)) ``` since it was backported to `5-1-stable` by c5dd451. - Remove ``` * Fallback `ActionController::Parameters#to_s` to `Hash#to_s`. ([Pull Request](rails#29630)) ``` since it was backported to `5-1-stable` by c1014e4 and `5-0-stable` by 0e71fc3. - Remove ``` * Make `take_failed_screenshot` work within engine. ([Pull Request](rails#30421)) ``` since it was backported to `5-1-stable` by 595a231. - Remove ``` * Fix optimized url helpers when using relative url root. ([Pull Request](rails#31261)) ``` since it was backported to `5-1-stable` by e9b7795. - Remove ``` * Update `distance_of_time_in_words` helper to display better error messages for bad input. ([Pull Request](rails#20701)) ``` since it was backported to `5-1-stable` by 2c97fbf. - Remove ``` * Generate field ids in `collection_check_boxes` and `collection_radio_buttons`. ([Pull Request](rails#29412)) ``` since it was backported to `5-1-stable` by 2d8c10a. - Remove ``` * Fix issues with scopes and engine on `current_page?` method. ([Pull Request](rails#29503)) ``` since it was backported to `5-1-stable` by 2135daf. - Remove ``` * Bring back proc with arity of 1 in `ActionMailer::Base.default` proc since it was supported in Rails 5.0 but not deprecated. ([Pull Request](rails#30391)) ``` since it was backported to `5-1-stable` by b2bedb1. - Remove ``` * Add type caster to `RuntimeReflection#alias_name`. ([Pull Request](rails#28961)) ``` since it was backported to `5-1-stable` by f644e7a. - Remove ``` * Loading model schema from database is now thread-safe. ([Pull Request](rails#29216)) ``` since it was backported to `5-1-stable` by 02926cf. and `5-0-stable` by 84bcfe5 - Remove ``` * Fix destroying existing object does not work well when optimistic locking enabled and `locking_column` is null in the database. ([Pull Request](rails#28926)) ``` since it was backported to `5-1-stable` by e498052. - Remove ``` * `ActiveRecord::Persistence#touch` does not work well when optimistic locking enabled and `locking_column`, without default value, is null in the database. ([Pull Request](rails#28914)) ``` since it was backported to `5-1-stable` by 1e2f63d. - Remove ``` * Previously, when building records using a `has_many :through` association, if the child records were deleted before the parent was saved, they would still be persisted. Now, if child records are deleted before the parent is saved on a `has_many :through` association, the child records will not be persisted. ([Pull Request](rails#29593)) ``` since it was backported to `5-1-stable` by a22c39e. - Remove ``` * Query cache was unavailable when entering the `ActiveRecord::Base.cache` block without being connected. ([Pull Request](rails#29609)) ``` since it was backported to `5-1-stable` by fd6c8cd and `5-0-stable` by 9f2532b. - Remove ``` * `Relation#joins` is no longer affected by the target model's `current_scope`, with the exception of `unscoped`. ([Commit](rails@5c71000)) ``` since it was backported to `5-1-stable` by 3630d63. - Remove ``` * Fix `unscoped(where: [columns])` removing the wrong bind values. ([Pull Request](rails#29780)) ``` since it was backported to `5-1-stable` by d378fcb. - Remove ``` * When a `has_one` association is destroyed by `dependent: destroy`, `destroyed_by_association` will now be set to the reflection, matching the behaviour of `has_many` associations. ([Pull Request](rails#29855)) ``` since it was backported to `5-1-stable` by 8254a8b. - Remove ``` * Fix `COUNT(DISTINCT ...)` with `ORDER BY` and `LIMIT` to keep the existing select list. ([Pull Request](rails#29848)) ``` since it was backported to `5-1-stable` by 0e8d4ed. - Remove ``` * Ensure `sum` honors `distinct` on `has_many :through` associations. ([Commit](rails@566f1fd)) ``` since it was backported to `5-1-stable` by c0a1dc2. - Remove ``` * Fix `COUNT(DISTINCT ...)` for `GROUP BY` with `ORDER BY` and `LIMIT`. ([Commit](rails@5668dc6)) ``` since it was backported to `5-1-stable` by 87ca68e. - Remove ``` * MySQL: Don't lose `auto_increment: true` in the `db/schema.rb`. ([Commit](rails@9493d45)) ``` since it was backported to `5-1-stable` by 8b6e694. - Remove ``` * Fix longer sequence name detection for serial columns. ([Pull Request](rails#28339)) ``` since it was backported to `5-1-stable` by af9c170 and `5-0-stable` by 7025b1d. - Remove ``` * Fix `bin/rails db:setup` and `bin/rails db:test:prepare` create wrong ar_internal_metadata's data for a test database. ([Pull Request](rails#30579)) ``` since it was backported to `5-1-stable` by bb67b5f and `5-0-stable` by 60437e6. - Remove ``` * Fix conflicts `counter_cache` with `touch: true` by optimistic locking. ([Pull Request](rails#31405)) ``` since it was backported to `5-1-stable` by 5236dda. - Remove ``` * Fix `count(:all)` to correctly work `distinct` with custom SELECT list. ([Commit](rails@c6cd9a5)) ``` since it was backported to `5-1-stable` by 6beb4de. - Remove ``` * Fix to invoke callbacks when using `update_attribute`. ([Commit](rails@732aa34)) ``` since it was backported to `5-1-stable` by 6346683. - Remove ``` * Use `count(:all)` in `HasManyAssociation#count_records` to prevent invalid SQL queries for association counting. ([Pull Request](rails#27561)) ``` since it was backported to `5-1-stable` by eef3c89. - Remove ``` * Fix `count(:all)` with eager loading and having an order other than the driving table. ([Commit](rails@ebc09ed)) ``` since it was backported to `5-1-stable` by 6df9b69. - Remove ``` * PostgreSQL: Allow pg-1.0 gem to be used with Active Record. ([Pull Request](rails#31671)) ``` since it was backported to `5-1-stable` by a9c06f6. - Remove ``` * Fix that after commit callbacks on update does not triggered when optimistic locking is enabled. ([Commit](rails@7f9bd03)) ``` since it was backported to `5-1-stable` by aaee10e. - Remove ``` * Fix regression in numericality validator when comparing Decimal and Float input values with more scale than the schema. ([Pull Request](rails#28584)) ``` since it was backported to `5-1-stable` by 5b1c3e5. Note that there was incorrect link to PR, original PR is rails#29249. - Remove ``` * Fix to working before/after validation callbacks on multiple contexts. ([Pull Request](rails#31483)) ``` since it was backported to `5-1-stable` by 0f7046a. - Remove ``` * Fix implicit coercion calculations with scalars and durations. ([Pull Request](rails#29163), [Pull Request](rails#29971)) ``` since it was backported to `5-1-stable` by 51ea27c, 4d82e2a. - Remove ``` * Fix modulo operations involving durations. ([Commit](rails@a54e13b)) ``` since it was backported to `5-1-stable` by 233fa7e. - Remove ``` * Return all mappings for a timezone identifier in `country_zones`. ([Commit](rails@cdce6a7)) ``` since it was backported to `5-1-stable` by 0222ebb. - Remove ``` * Add support for compatibility with redis-rb gem for 4.0 version. ([Pull Request](rails#30748)) ``` since it was backported to `5-1-stable` by 3789531. Related to rails#32252. Related to rails#32222, rails#32222 (comment). Follow up a489cc8.
Problem
The error message for bad input to the
distance_of_time_in_words
and related methods is cryptic. For example, ifnil
is provided as an argument totime_ago_in_words
, this is the error:I believe a more palatable result in this case would be to raise an
ArgumentError
for invalid input.(Proposed) Solution
The provided implementation is a bit naive in that it only checks for
nil
arguments. While investigating the problem, I realized there are several different ways in which the input may be "bad".Argument is
nil
The
from_time
andto_time
arguments are incompatible with one another. e.g.moar?
Generally it seems like the interface requires either two numeric arguments, or two
to_time
convertible arguments.To that end, it felt like this issue may benefit from the input of parties more familiar with the code. I would be honored and willing to help with the solution. If I am able to come up with a more "elegant" solution to the type situation, I will gladly post it back here.
Thank you! 💫
TODO