views:

199

answers:

3

Wondering if there’s a plugin or best way of setting up an ActiveRecord class so that, for example, when a record enter the "published" state, certain attributes are frozen so that they could not be tampered with.

+3  A: 

You can freeze an entire AR::B object by setting @readonly to true (in a method), but that will lock out all attributes.

The way I would recommend is by defining attribute setter methods that check for the current state before passing to super:

class Post < ActiveRecord::Base
  def author=(author)
    super unless self.published?
  end

  def content=(content)
    super unless self.published?
  end
end

[EDIT] Or for a large amount of attributes:

class Post < ActiveRecord::Base
  %w(author content comments others).each do |method|
    class_eval <<-"end_eval", binding, __FILE__, __LINE__
      def #{method}=(val)
        super unless self.published?
      end
    end_eval
  end
end

Which of course I would advocate pulling into a plugin to share with others, and add a nice DSL for accessing like: disable_attributes :author, :content, :comments, :when => :published?

Colin Curtin
Thanks for the feedback Colin. For a large number of attributes I suppose I could do a class eval to overwrite these setters by passing in an array of attributes that need to be locked, eh?
Gordon Isnor
Precisely. I debated whether I should write it as a class_eval, but decided against it for readability. I'll tack it on though for others.
Colin Curtin
+1  A: 

You could add a custom validation to block changes to attributes if you're in a certain state. You could hard code things directly into the validation. But I prefer the slightly more robust approach using constants defining a whitelist (list of attributes that are allowed to change in a state) or a blacklist (list of attributes not allowed to change in a state).

Here's an example of both approaches. Each approach assumes there is a state method in your model that returns the current/new state as a string.

White List Approach

WhiteListStateLockMap = {
  "state_1" => [
    "first_attribute_allowed_to_change_in_state_1",
    "second_attribute_allowed_to_change_in_state_1",
    ...
  ],
  "state_2" => [
    "first_attribute_allowed_to_change_in_state_2",
    "second_attribute_allowed_to_change_in_state_2",
    ...
  ],
  ...
}

validates :state_lock

def state_lock
  # ensure that all changed elements are on the white list for this state.
  unless changed & WhiteListStateLockMap[state] == changed
    # add an error for each changed attribute absent from the white list for this state.
    (changed - WhiteListStateLockMap[state]).each do |attr|
      errors.add attr, "Locked while #{state}"
    end
  end
end

Black List Approach

BlackListStateLockMap = {
  "state_1" => [
    "first_attribute_not_allowed_to_change_in_state_1,
    "second_attribute_not_allowed_to_change_in_state_1,
    ...
  ],
  "state_2" => [
    "first_attribute_not_allowed_to_change_in_state_2",
    "second_attribute_not_allowed_to_change_in_state_2",
    ...
  ],
  ...
}

validates :state_lock

def state_lock
  # ensure that no changed attributes are on the black list for this state.
  unless (changed & BlackListStateLockMap[state]).empty?
    # add an error for all changed attributes on the black list for this state.
    (BlackListStateLockMap[state] & changed).each do |attr|
      errors.add attr, "Locked while #{state}"
    end
  end
end
EmFi
After I posted, I realized my solution was essentially similar to this one, except written differently. I believe mine is more explicit, but this is good too, as it is more declarative.
François Beausoleil
Gordon Isnor
EmFi
+4  A: 

Editing attributes which shouldn't be edited is a validation error:

class Post < ActiveRecord::Base
  validate :lock_down_attributes_when_published

  private

  def lock_down_attributes_when_published
    return unless published?

    message = "must not change when published"
    errors.add(:title, message) if title_changed?
    errors.add(:published_at, message) if published_at_changed?
  end
end

This uses the ActiveRecord::Dirty extensions introduced in 2.2 or so.

François Beausoleil
Thanks for the feedback François, this looks like another great way of doing this.
Gordon Isnor