Search code examples
ruby-on-railsrubyvalidationactiverecordsingle-table-inheritance

Using a Child's constant within a Parent's Validation


With the below code I am able to access the child's constant (ADDRESS_FIELDS) no problem within the initialize method (by using self.class::ADDRESS_FIELDS) but am unable to access it within the validation (getting NameError: uninitialized constant Class::ADDRESS_FIELDS). Any ideas on how to use the child's constant within the parent validation? There are other children of PaymentType with their own values of ADDRESS_FIELDS.

class PaymentType < ActiveRecord::Base
  attr_accessible :address

  validates :address, hash_key: { presence: self.class::ADDRESS_FIELDS }

  def initialize(attributes = {}, options = {})
    super
    return self if address.present?

    address = {}
    self.class::ADDRESS_FIELDS.each do |field|
      address[field] = nil
    end
    self.address = address
  end
end

class WireTransfer < PaymentType
  ADDRESS_FIELDS = %i(first_name last_name bank_name routing_number account_number)
end

Solution

  • Nice chatting with you yesterday. To recap, your motivation for putting the validates call in PaymentType is DRYing up your code (because it's identical in all children of PaymentType).

    The problem is that Ruby loads PaymentType before it loads WireTransfer (due to inheritance, I believe) so validates can't find ADDRESS_FIELDS (because it's defined on WireTransfer, which hasn't been loaded yet). That's the first test in the RSpec test, below.

    rspec 'spec/stack_overflow/child_constant_parent_validation_spec.rb' -fd
    
    Using a Child's Constant within a Parent's Validation
      when 'validates' in parent
        raises error
    

    Now, you could just put validates in each child. But, that kinda sucks because you have to define it in every child - yet it's the same across all children. So, you're not as DRY as you'd like to be. That's the second test, below.

    rspec 'spec/stack_overflow/child_constant_parent_validation_spec.rb' -fd
    
    Using a Child's Constant within a Parent's Validation
      when 'validates' in parent
        raises error
      when 'validates' in child
        doesn't raise an error
        has the correct class methods
        has the correct instance methods
        kinda sucks because 'validates' has to be defined in every child.
    

    So, are you doomed to sogginess? Not necessarily. You could put your validates in a module so that you can define it once and use it everywhere. You would then include the module in your children classes. The trick is (1) using the included hook and accessing base::ADDRESS_FIELDS, and (2) making sure that you include the module AFTER you have set ADDRESS_FIELDS in the child. That's the third test, below.

    rspec 'spec/stack_overflow/child_constant_parent_validation_spec.rb' -fd
    
    Using a Child's Constant within a Parent's Validation
      when 'validates' in parent
        raises error
      when 'validates' in child
        doesn't raise an error
        has the correct class methods
        has the correct instance methods
        kinda sucks because 'validates' has to be defined in every child.
      when 'validates' in module
        doesn't raise an error
        has the correct class methods
        has the correct instance methods
        is a little better because you can define 'validates' once and use in all children
    
    Finished in 0.00811 seconds (files took 0.1319 seconds to load)
    9 examples, 0 failures
    

    Of course, you still have to remember to include the module in every child, but that shouldn't be too bad. And better than defining validates everywhere.

    After everything, your classes might look something like:

    class PaymentType
      class << self
        def a_useful_class_method_from_payment_base; end
      end
      def a_useful_instance_method_from_payment_base; end
    end
    
    module PaymentTypeValidations
      def self.included(base)
        validates :address, hash_key: { presence: base::ADDRESS_FIELDS }
      end
    end
    
    class WireTransfer < PaymentType
      ADDRESS_FIELDS = %i(first_name last_name bank_name routing_number account_number)
      include PaymentTypeValidations
    end
    
    class Bitcoin < PaymentType
      ADDRESS_FIELDS = %i(wallet_address)
      include PaymentTypeValidations
    end
    

    I've put the entire RSpec test below in case you want to run it yourself.

    RSpec.describe "Using a Child's Constant within a Parent's Validation " do
    
      before(:all) do
    
        module Validations
          def validates(field, options={}) 
            define_method("valid?") do
            end
            define_method("valid_#{field}?") do
            end
          end
        end
    
        module PaymentType
          class Base
            extend Validations
            class << self
              def a_useful_class_method_from_payment_base; end
            end
            def a_useful_instance_method_from_payment_base; end
          end
        end
    
        module WireTransfer
        end
    
      end
    
      context "when 'validates' in parent" do
        it "raises error" do
    
          expect{
    
            class PaymentType::WithValidates < PaymentType::Base
              validates :address, hash_key: { presence: self::ADDRESS_FIELDS }
            end
    
            class WireTransfer::Base < PaymentType::WithValidation
              ADDRESS_FIELDS = %i(first_name last_name bank_name routing_number account_number)
            end
    
          }.to raise_error(NameError)
    
        end
      end
    
      context "when 'validates' in child" do
        it "doesn't raise an error" do
    
          expect{
    
            class PaymentType::WithoutValidates < PaymentType::Base
            end
    
            class WireTransfer::WithValidates < PaymentType::WithoutValidates
              ADDRESS_FIELDS = %i(first_name last_name bank_name routing_number account_number)
              validates :address, hash_key: { presence: self::ADDRESS_FIELDS }
            end
    
          }.to_not raise_error
        end
        it "has the correct class methods" do
          expect(WireTransfer::WithValidates).to respond_to("a_useful_class_method_from_payment_base")
        end
        it "has the correct instance methods" do
          wire_transfer = WireTransfer::WithValidates.new
          ["valid?","valid_address?","a_useful_instance_method_from_payment_base"].each do |method|
            expect(wire_transfer).to respond_to(method)
          end
        end
        it "kinda sucks because 'validates' has to be defined in every child." do
          module Bitcoin
            class Base < PaymentType::WithoutValidates
            end
          end
          bitcoin = Bitcoin::Base.new
          ["valid?","valid_address?"].each do |method|
            expect(bitcoin).to_not respond_to(method)
          end
        end
      end
      
      context "when 'validates' in module" do
        it "doesn't raise an error" do
          expect{
    
            module PaymentTypeValidations
              extend Validations
              def self.included(base)
                validates :address, hash_key: { presence: base::ADDRESS_FIELDS }
              end
            end
    
            class WireTransfer::IncludingValidationsModule < PaymentType::WithoutValidates
              ADDRESS_FIELDS = %i(first_name last_name bank_name routing_number account_number)
              include PaymentTypeValidations
            end
    
          }.to_not raise_error
    
        end
    
        it "has the correct class methods" do
          expect(WireTransfer::IncludingValidationsModule).to respond_to("a_useful_class_method_from_payment_base")
        end
    
        it "has the correct instance methods" do
          wire_transfer = WireTransfer::IncludingValidationsModule.new
          ["valid?","valid_address?","a_useful_instance_method_from_payment_base"].each do |method|
            expect(wire_transfer).to respond_to(method)
          end
        end
    
        it "is a little better because you can define 'validates' once and use in all children" do
          class Bitcoin::IncludingValidationsModule < PaymentType::WithoutValidates
            ADDRESS_FIELDS = %i(wallet_address)
            include PaymentTypeValidations
          end
    
          bitcoin = Bitcoin::IncludingValidationsModule.new
          ["valid?","valid_address?","a_useful_instance_method_from_payment_base"].each do |method|
            expect(bitcoin).to respond_to(method)
          end
    
        end
    
    
      end
    
    end