Using a duration field in a Rails model - ruby-on-rails

I'm looking for the best way to use a duration field in a Rails model. I would like the format to be HH:MM:SS (ex: 01:30:23). The database in use is sqlite locally and Postgres in production.
I would also like to work with this field so I can take a look at all of the objects in the field and come up with the total time of all objects in that model and end up with something like:
30 records totaling 45 hours, 25 minutes, and 34 seconds.
So what would work best for?
Field type for the migration
Form field for the CRUD forms (hour, minute, second drop downs?)
Least expensive method to generate the total duration of all records in the model

Store as integers in your database (number of seconds, probably).
Your entry form will depend on the exact use case. Dropdowns are painful; better to use small text fields for duration in hours + minutes + seconds.
Simply run a SUM query over the duration column to produce a grand total. If you use integers, this is easy and fast.
Additionally:
Use a helper to display the duration in your views. You can easily convert a duration as integer of seconds to ActiveSupport::Duration by using 123.seconds (replace 123 with the integer from the database). Use inspect on the resulting Duration for nice formatting. (It is not perfect. You may want to write something yourself.)
In your model, you'll probably want attribute readers and writers that return/take ActiveSupport::Duration objects, rather than integers. Simply define duration=(new_duration) and duration, which internally call read_attribute / write_attribute with integer arguments.

In Rails 5, you can use ActiveRecord::Attributes to store ActiveSupport::Durations as ISO8601 strings. The advantage of using ActiveSupport::Duration over integers is that you can use them for date/time calculations right out of the box. You can do things like Time.now + 1.month and it's always correct.
Here's how:
Add config/initializers/duration_type.rb
class DurationType < ActiveRecord::Type::String
def cast(value)
return value if value.blank? || value.is_a?(ActiveSupport::Duration)
ActiveSupport::Duration.parse(value)
end
def serialize(duration)
duration ? duration.iso8601 : nil
end
end
ActiveRecord::Type.register(:duration, DurationType)
Migration
create_table :somethings do |t|
t.string :duration
end
Model
class Something < ApplicationRecord
attribute :duration, :duration
end
Usage
something = Something.new
something.duration = 1.year # 1 year
something.duration = nil
something.duration = "P2M3D" # 2 months, 3 days (ISO8601 string)
Time.now + something.duration # calculation is always correct

I tried using ActiveSupport::Duration but had trouble getting the output to be clear.
You may like ruby-duration, an immutable type that represents some amount of time with accuracy in seconds. It has lots of tests and a Mongoid model field type.
I wanted to also easily parse human duration strings so I went with Chronic Duration. Here's an example of adding it to a model that has a time_spent in seconds field.
class Completion < ActiveRecord::Base
belongs_to :task
belongs_to :user
def time_spent_text
ChronicDuration.output time_spent
end
def time_spent_text= text
self.time_spent = ChronicDuration.parse text
logger.debug "time_spent: '#{self.time_spent_text}' for text '#{text}'"
end
end

I've wrote a some stub to support and use PostgreSQL's interval type as ActiveRecord::Duration.
See this gist (you can use it as initializer in Rails 4.1): https://gist.github.com/Envek/7077bfc36b17233f60ad
Also I've opened pull requests to the Rails there:
https://github.com/rails/rails/pull/16919

Related

How does one get the "next" record from database sorted by a specific attribute without loading all the records?

Here's the situation:
I have an Event model and I want to add prev / next buttons to a view to get the next event, but sorted by the event start datetime, not the ID/created_at.
So the events are created in the order that start, so I can compare IDs or get the next highest ID or anything like that. E.g. Event ID 2 starts before Event ID 3. So Event.next(3) should return Event ID 2.
At first I was passing the start datetime as a param and getting the next one, but this failed when there were 2 events with the same start. The param start datetime doesn't include microseconds, so what would happen is something like this:
order("start > ?",current_start).first
would keep returning the same event over and over because current_start wouldn't include microseconds, so the current event would technically be > than current_start by 0.000000124 seconds or something like that.
The way I got to work for everything was with a concern like this:
module PrevNext
extend ActiveSupport::Concern
module ClassMethods
def next(id)
find_by(id: chron_ids[current_index(id)+1])
end
def prev(id)
find_by(id: chron_ids[current_index(id)-1])
end
def chron_ids
#chron_ids ||= order("#{order_by_attr} ASC").ids
end
def current_index(id)
chron_ids.find_index(id)
end
def order_by_attr
#order_by_attr ||= 'created_at'
end
end
end
Model:
class Event < ActiveRecord::Base
...
include PrevNext
def self.order_by_attr
#order_by_attr ||= "start_datetime"
end
...
end
I know pulling all the IDs into an array is bad and dumb* but i don't know how to
Get a list of the records in the order I want
Jump to a specific record in that list (current event)
and then get the next record
...all in one ActiveRecord query. (Using Rails 4 w/ PostgreSQL)
*This table will likely never have more than 10k records, so it's not catastrophically bad and dumb.
The best I could manage was to pull out only the IDs in order and then memoize them.
Ideally, i'd like to do this by just passing the Event ID, rather than a start date params, since it's passed via GET param, so the less URL encoding and decoding the better.
There has to be a better way to do this. I posted it on Reddit as well, but the only suggested response didn't actually work.
Reddit Link
Any help or insight is appreciated. Thanks!
You can get the next n records by using the SQL OFFSET keyword:
china = Country.order(:population).first
india = City.order(:population).offset(1).take
# SELECT * FROM countries ORDER BY population LIMIT 1 OFFSET 1
Which is how pagination for example often is done:
#countries = Country.order(:population).limit(50)
#countries = scope.offset( params[:page].to_i * 50 ) if params[:page]
Another way to do this is by using would be query cursors. However ActiveRecord does not support this and it building a generally reusable solution would be quite a task and may not be very useful in the end.

Rails: active record saving time:type with unwanted format

I have a table call periodo with the attribute hour. i pass my time param in this way
hour = Time.parse( splitLine[1] ) #where splitLine[1] is my time but in string
periodo = Periodo.new(:hour => hour.strftime("%H:%M"))
periodo.save
but active record save the records in this way hour: "2000-01-01 07:00:00" , I already set the format in /config/initializers/time_formats.rb
Time::DATE_FORMATS[:default] = "%H:%M"
Date::DATE_FORMATS[:default] = "%H:%M"
and in en.yml too
en:
hello: "Hello world"
time:
formats:
default: "%H:%M"
date:
formats:
default: "%H:%M"
but my records are still saving the year and month :( what i have to do to save just the hour and minutes ???
greetings
Date formats are only valid within your application, not within the database - they are only responsible for the way time objects are displayed to your users and will not affect the way data is stored.
Unfortunately, there is no such a concept like time only in the database (at least I haven't heard about any, and trust me I did search for it as I need it in my current project)
Simplest solution
However, in many cases it makes sense to store only the time of the event. In current project we decided to store it in format of integer 60 * hour + minutes. This is unfortunately where we stopped in the project. :(
One step further
You can then create a helper class (just a scaffold - more work needed like validations casting etc):
class SimpleTime
attr_accessor :hour, :minute
def initialize(hour, minute)
#hour, #minute = hour, minute
end
def self.parse(integer)
return if integer.blank?
new(integer / 60, integer % 60)
end
def to_i
60 * #hour + #minute
end
end
and then override a setter and getter:
class Model < ActiveRecord::Base
def time
#time ||= SimpleTime.parse(super)
end
def time=(value)
super(value.to_i)
end
end
Further fun
Now there are more things you could do. You can for example write extension simple_time to active_record which will automatically redefine setters and getters for a list of passed attributes. You can even wrap it in a small gem and make it real-world-proof (missing validations, string format parsers, handling _before_type_cast values etc).
You have to do nothing. That is activerecord convention on time storing. So if you want to have automatically parsed time in your model from sql database you have to store it in the way AR does. But if you really want to store only hours and minutes, you should change your scheme and use just string instead of datetime in AR migration. So you can store your time like that. and in the model class you can override the attribute getter like:
def some_time
Time.parse(#some_time)
end
Then you can get parsed time object when you call attribute. But that is a bad way actually.

How to create method that converts attribute's value to new value

I have a form in which users input a number for the attribute :bytesize, which has an integer datatype. The number represents the amount of bytes for my object #catcher.
I'd like to have a method that will convert the value of :bytesize to megabytes. That is, I'd like to be able to run #catcher.mbsize, and that will display the number of megabytes for that object.
I'm pretty new to Rails, so my apologies if this seems obvious.
Conversion methods are pretty straight-forward:
class Catcher
def mbsize
self.bytesize / (1 << 20)
end
end
Remember that attributes are internally stored as instance variables, so attr_accessor :bytesize is stored in #bytesize.
You need to add mbsize column to your db.
In controller:
def create
#other code
def mbsize
#bytesize / (1 << 20)
end
#catcher.mbsize=mbsize
#cather.save
end
EDIT:
If you don't need related DB record, you can simply define this method in Catcher model:
def mbsize
bytesize=self.bytesize
mbsize=#your method of converting
end
By some reason your bytesize is sting. You can convert it into integer by .to_i method

Save an integer with time_zone_select

I am following the railscast on time zones found here- http://railscasts.com/episodes/106-time-zones-revised
The following is what I am using for the time zone select input. Currently the form saves a string value in the database (ie. "Alaska"). Instead, I'd like to save the UTC offset as an integer. How would I go about doing this?
<%= f.time_zone_select :time_zone, ActiveSupport::TimeZone.us_zones
You have two options, you can either use select or you can create a virtual attribute. I would probably prefer the latter option.
Since TimeZone doesn't directly give you an offset in hours (only in seconds or as a string), you can't use collection_select directly, but you can use select like this (utc_offset is in seconds):
f.select :time_zone, ActiveSupport::TimeZone.us_zones.map { |z| [z.name, z.utc_offset / 1.hour] }
If you use a virtual attribute, use the code you are already using but use :time_zone_name instead of :time_zone and then update your model like this:
def time_zone_name=(value)
time_zone = ActiveSupport::TimeZone.new(value).utc_offset / 1.hour
end
def time_zone_name
# time_zone is a number like -9
ActiveSupport::TimeZone.new(time_zone).name
end
I prefer the last option because it enables you to set the time zone by offset or by name even from rails console or from anywhere where you wish to set it.

How to dynamically construct attribute names in Sunspot searchable block

My search includes the ability to scope a query by a price range, the day of the week, and a guest count.
However, the price is different on each day of the week, and the price changes (not linearly) by the number of guests. The best way I can think of to index the data is to create a different field for each day/guest combo, e.g.,
searchable do
integer :price_sunday_2_guests do
price(:sunday, 2)
end
integer :price_sunday_3_guests do
price(:sunday, 3)
end
...
integer :price_monday_2_guests do
price(:monday, 2)
end
...
# and so on...
end
Obviously, I don't want to type all that in. I want to construct those attributes in the searchable blocks. More like:
searchable do
Date::DAYNAMES.each do |day_name|
day = DateTime.strptime(day_name, '%A')
(guests_min..guests_max).each do |guests|
sym = (day_name.downcase + '_' + guests.to_s + 'guests_price_abs_max').to_sym
integer sym do
price(day_name, guests)
end
end
end
end
But I get the following exception:
NoMethodError: undefined method `guests_min' for #<Sunspot::DSL::Fields:0x000001080bdcf0>
It does not complain about the constant Date::DAYNAMES.
It seems that Sunspot tries to interpret any method token as a field. Which, I suppose would even make sense if I understood it better.
So, my question, is there a smart way for me to do this? Do I need to just hardcode a range and let my price method return an empty value? Is there some mechanism available in the searchable block I'm not aware of?

Resources