justingordon
justingordon

Reputation: 12923

How to have one concern include another concern with a parameter

Suppose you have this code from the documentation of ActiveSupport::Concern, but you want to the included block of Foo to have something different depending on the module or class that including Foo.

In the specific problem I'm trying to solve, I have a set of validations for addresses, but the address fields will be named home_zip_code or work_zip_code, and I want the inclusion of the validation concern to know the prefix of the zip_code field.

require 'active_support/concern'

module Foo
  extend ActiveSupport::Concern
  included do
    # have some_value be accessible 
    def self.method_injected_by_foo
      ...
    end
  end
end

module Bar
  extend ActiveSupport::Concern
  # set some_value that will used when Foo is included 
  include Foo

  included do
    self.method_injected_by_foo
  end
end

class Host
  include Bar # It works, now Bar takes care of its dependencies
end

I've placed this discussion here: http://forum.railsonmaui.com/t/how-make-a-concern-parameterized/173

The following 2 options work. I'm wondering which is preferable.

A Concern That Uses a Class Method

This is the concern that needs to be "parameterized":

module Addressable
  extend ActiveSupport::Concern

  included do
    zip_field = "#{address_prefix}_zip_code".to_sym

    zip_code_regexp = /^\d{5}(?:[-\s]\d{4})?$/

    validates zip_field, format: zip_code_regexp, allow_blank: true
end

I found 2 ways to set the address_prefix before including the Addressable concern.

When the concern module is included in the class

The class method needs to be defined before including the concern

cattr_accessor :address_prefix
self.address_prefix = "home"
include Addressable

or like this

def self.address_prefix
    "home"
end
include Addressable

When the concern module is included in another module

The trick here is to override self.append_features and to add the method.

  def self.append_features(base)
    base.class_eval do
      def self.address_prefix
        "home"
      end
    end
    super
  end

or

  def self.append_features(base)
    base.cattr_accessor :address_prefix
    base.address_prefix = "home"
    super
  end

Questions

  1. What preferable, the cattr_accessor way or defining the class method?
  2. For the concern within a concern situation, is overriding self.append_features the correct hook?
  3. Is class_eval the right call to create the method, rather than class exec? Or really doesn't matter if the code doesn't need access to instance variables. Module docs here.
  4. How could I include this concern twice, say for a prefix of "work" and a prefix of "home" so the validations would apply to both. Clearly setting the class method on the including class would not work. Or maybe it would if the method is redefined between inclusions? Any cleaner way?

Upvotes: 9

Views: 3734

Answers (1)

Andrew Schwartz
Andrew Schwartz

Reputation: 4657

The cattr_accessor or class method way is a good one I've used before. cattr_accessor is defining a method, so either is fine. One difference involves inheritance, I think cattr_accessor variables are not inherited. Might want to look into that if that might be a concern.

But I'd actually at this point approach this a whole different way. Simplest for me would be to define a class method that can take a parameter, such as acts_as_addressable(address_prefix. You can define this method in a module, Addressible, where you can do this:

extend Addressible
acts_as_addressbile('home')
acts_as_addressible('work')

You can take a shortcut that a lot of gems take and actually force your module into ActiveRecord::Base, preventing you from needing to use the extend in each class, but I personally hate that style.

Depending on what else your included block is doing, maybe validates_zip_code is a better name for the class method. For style, I'd recommend actually taking the full name of the field. Slightly more typing, much more readable:

module ZipCodeValidatable
  ZIP_CODE_REGEX = /^\d{5}(?:[-\s]\d{4})?$/
  def validates_zip_code(zip_field)
    validates zip_field, format: ZIP_CODE_REGEX, allow_blank: true
  end
end

class MyRecord << ActiveRecord::Base
  extend ZipCodeValidatable
  validates_zip_code("work_zip_code")
  validates_zip_code("home_zip_code")
end

If validating the zip code is all you're doing, then you could use a custom validator. Looks to me like you can use the second section on ActiveModel::EachValidator. I've never used these, but my read it it would look like:

class ZipCodeValidator < ActiveModel::EachValidator
  ZIP_CODE_REGEX = /^\d{5}(?:[-\s]\d{4})?$/
  def validate(record, attribute, value)
    unless ZIP_CODE_REGEX =~ value
      errors[attribute] << "Invalid zip code in field #{attribute}"
    end
  end
end

Upvotes: 0

Related Questions