bookcasey
bookcasey

Reputation: 40473

DRY up this model with virtual attributes

In my form I have a virtual attributes that allows me to accept mixed numbers (e.g. 38 1/2) and convert them to decimals. I also have some validations (I'm not sure I'm handling this right) that throws an error if something explodes.

class Client < ActiveRecord::Base
  attr_accessible :mixed_chest

  attr_writer :mixed_chest

  before_save :save_mixed_chest

  validate :check_mixed_chest

  def mixed_chest
    @mixed_chest || chest
  end

  def save_mixed_chest
    if @mixed_chest.present?
      self.chest = mixed_to_decimal(@mixed_chest)
    else
       self.chest = ""
    end
  end

  def check_mixed_chest
    if @mixed_chest.present? && mixed_to_decimal(@mixed_chest).nil?
      errors.add :mixed_chest, "Invalid format. Try 38.5 or 38 1/2"
    end
  rescue ArgumentError
    errors.add :mixed_chest, "Invalid format. Try 38.5 or 38 1/2"
  end

  private

  def mixed_to_decimal(value)
    value.split.map{|r| Rational(r)}.inject(:+).to_d
  end

end 

However, I'd like to add another column, wingspan, which would have the virtual attribute :mixed_wingspan, but I'm not sure how to abstract this to reuse it—I will be using the same conversion/validation for several dozen inputs.

Ideally I'd like to use something like accept_mixed :chest, :wingspan ... and it would take care of the custom getters, setters, validations, etc.

EDIT:

I'm attempting to recreate the functionality with metaprogramming, but I'm struggling in a few places:

def self.mixed_number(*attributes)
  attributes.each do |attribute|
    define_method("mixed_#{attribute}") do
      "@mixed_#{attribute}" || attribute
    end
  end
end

mixed_number :chest

This sets chest to "@mixed_chest"! I'm trying to get the instance variable @mixed_chest like I have above.

Upvotes: 0

Views: 112

Answers (1)

Andrew Haines
Andrew Haines

Reputation: 6644

You're going to want a custom validator

Something like

class MixedNumberValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    if value.present? && MixedNumber.new(value).to_d.nil?
      record.errors[attribute] << (options[:message] || "Invalid format. Try 38.5 or 38 1/2")
    end
  end
end

Then you can do

validates :chest, mixed_number: true

Note that I'd extract the mixed_to_decimal stuff into a separate class

class MixedNumber
  def initialize(value)
    @value = value
  end

  def to_s
    @value
  end

  def to_d
    return nil if @value.blank?
    @value.split.map{|r| Rational(r)}.inject(:+).to_d
  rescue ArgumentError
    nil
  end
end

and that this definition lets you drop the if statement in the save_chest method.

Now you just need to do some metaprogramming to get everything going, as I suggested in my answer to your other question. You'll basically want something like

def self.mixed_number(*attributes)
  attributes.each do |attribute|
    define_method("mixed_#{attribute}") do
      instance_variable_get("@mixed_#{attribute}") || send(attribute)
    end

    attr_writer "mixed_#{attribute}"

    define_method("save_mixed_#{attribute}") do
      # exercise for the reader ;)
    end

    before_save "save_#{attribute}"
    validates "mixed_#{attribute}", mixed_number: true
  end
end

mixed_number :chest, :waist, :etc

Upvotes: 1

Related Questions