Setting & getting virtual attributes in Rails model
Asked Answered
G

2

6

I'm looking for a rails-y way to approach the following:

Two datetime attributes in an Event model:

start_at: datetime
end_at:   datetime

I would like to use 3 fields for accessing them in a form:

event_date
start_time
end_time

The problem I'm having is how to keep the actual and the virtual attributes in "sync" so the model can be updated via the form and/or directly via start_at & end_at.

class Event < ActiveRecord::Base
  attr_accessible :end_at, :start_at, :start_time, :end_time, :event_date
    attr_accessor :start_time, :end_time, :event_date

  after_initialize  :get_datetimes # convert db format into accessors
  before_validation :set_datetimes # convert accessors into db format

  def get_datetimes
    if start_at && end_at
      self.event_date ||= start_at.to_date.to_s(:db)   # yyyy-mm-dd 
      self.start_time ||= "#{'%02d' % start_at.hour}:#{'%02d' % start_at.min}" 
      self.end_time   ||= "#{'%02d' % end_at.hour}:#{'%02d' % end_at.min}" 
    end
  end

  def set_datetimes
    self.start_at = "#{event_date} #{start_time}:00"
    self.end_at   = "#{event_date} #{end_time}:00"
  end
end

Which works:

1.9.3p194 :004 > e = Event.create(event_date: "2012-08-29", start_time: "18:00", end_time: "21:00")

 => #<Event id: 3, start_at: "2012-08-30 01:00:00", end_at: "2012-08-30 04:00:00",  created_at: "2012-08-22 19:51:53", updated_at: "2012-08-22 19:51:53"> 

Until setting actual attributes directly (end_at set back to end_time on validation):

1.9.3p194 :006 > e.end_at = "2012-08-30 06:00:00 UTC +00:00"
 => "2012-08-30 06:00:00 UTC +00:00" 
1.9.3p194 :007 > e
 => #<Event id: 3, start_at: "2012-08-30 01:00:00", end_at: "2012-08-30 06:00:00", created_at: "2012-08-22 19:51:53", updated_at: "2012-08-22 19:51:53"> 
1.9.3p194 :008 > e.save
   (0.1ms)  BEGIN
   (0.4ms)  UPDATE "events" SET "end_at" = '2012-08-30 04:00:00.000000', "start_at" = '2012-08-30 01:00:00.000000', "updated_at" = '2012-08-22 20:02:15.554913' WHERE "events"."id" = 3
   (2.5ms)  COMMIT
 => true 
1.9.3p194 :009 > e
 => #<Event id: 3, start_at: "2012-08-30 01:00:00", end_at: "2012-08-30 04:00:00", created_at: "2012-08-22 19:51:53", updated_at: "2012-08-22 20:02:15"> 
1.9.3p194 :010 > 

My assumption is that I also need to customize the "actual" attribute's setters but I'm not sure how to do that w/out screwing up default behavior. Thoughts? Perhaps there a more "Rails-y" "callback-y" way to handle this?

Gwyn answered 22/8, 2012 at 20:26 Comment(1)
Great question! It doesn't end there. Often, when you put virtual attributes on form, you also want to have validation error messages to be in some sync between real and virtual attrs. Also, preserving standard form behavior for invalid input, when form is re-displayed with invalid values filled in, makes all this callback logic quite convoluted. I'd love to see some good pattern which handles all of this.Avifauna
G
6

Here's my take. I haven't tested it with ActiveRecord, but I left comments. Hope this helps.

class Event < ActiveRecord::Base

  attr_accessible :end_at, :start_at, :start_time, :end_time, :event_date
  attr_accessor :start_time, :end_time, :event_date

  def start_time
    @start_time || time_attr_from_datetime(start_at)
  end

  def start_time=(start_time_value)
    @start_time = start_time_value
    set_start_at
  end

  def end_time
    @end_time || time_attr_from_datetime(end_at)
  end

  def end_time=(end_time_value)
    @end_time = @end_time_value
    set_end_at
  end

  def event_date
    @event_date || start_at.to_date.to_s(:db)
  end

  def event_date=(event_date_value)
    @event_date = event_date_value
    set_start_at
    set_end_at
  end

  def start_at=(start_at_value)
    write_attribute(:start_at, start_at_value)  # Maybe you need to do write_attribute(:start_at, DateTime.parse(start_at_value)) here ???
    @start_time = time_attr_from_datetime(start_at)
  end

  def end_at=(end_at_value)
    write_attribute(:end_at, end_at_value)  # Maybe you need to do write_attribute(:end_at, DateTime.parse(end_at_value)) here ???
    @end_time = time_attr_from_datetime(end_at)
  end

  private
  def set_start_at
    self.start_at = DateTime.parse("#{event_date} #{start_time}:00")
  end

  def set_end_at
    self.end_at = DateTime.parse("#{event_date} #{end_time}:00")
  end

  def time_attr_from_datetime(datetime)
    "#{'%02d' % datetime.hour}:#{'%02d' % datetime.min}"
  end
end

EDIT: There's a definite pattern to getting and setting start_time and end_time. It could be abstracted a bit with meta-programming, but I thought that would make the example unclear.

Gemperle answered 22/8, 2012 at 23:23 Comment(0)
S
0

I would just not 'cache' the 'virtual' attributes at all, esp if you don't need your virtual ones to be "settable", only "gettable", which is what your example looks like.

  def event_date
    start_at.to_date.to_s(:db)   # yyyy-mm-dd 
  end
  def start_time
     "#{'%02d' % start_at.hour}:#{'%02d' % start_at.min}" 
  end
  def end_time
     "#{'%02d' % end_at.hour}:#{'%02d' % end_at.min}" 
  end

As soon as you start cacheing, you have to worry about invalidating the cached values -- you basically have an 'invalidating cached values' problem. There are a couple ways to make your original design work -- but I don't think the calculation being made there is expensive enough to justify the added complexity from memoizing/caching as you are doing. Just provide em on demand, and you don't need to worry about invalidating the cached values.

If you really really want to do what you initially propose, this might get you started: Callback for changed ActiveRecord attributes? (not sure if Rails has changed since that stackoverflow was written though)

Sensor answered 22/8, 2012 at 20:46 Comment(1)
This gives me access to event_date, start_time and end_time in the form. BUT these are form input values so, from what I can see from your answer, there is no way to take changes to these virtual attributes and apply them to the actual attributes which are saved with the record. What'd I miss?Gwyn

© 2022 - 2024 — McMap. All rights reserved.