1

I am trying to write more modular code for my rails apps, so have started playing more with including modules in classes. I have basic understanding of their function, but i am finding it hard to keep them flexible while remaining DRY.

Here is a current example.

I have a module called Contactable. It has two very basic functions.

  1. Ensures the right contact columns are present in the DB.
  2. Validates certain fields.

Here it is

module Contactable
  extend ActiveSupport::Concern
  ERROR = 'please ensure necessary fields are in place'

  included do
    REQUIRED_DATABASE_FIELDS.map { |rdf| raise "#{rdf} not included. #{ERROR}" unless column_names.include?(rdf)}
    REQUIRED_INPUT_FIELDS.map { |rif| validates rif.to_sym, presence: true}
  end
end

I would like contactable to be comprised of three other modules (Phoneable, Emailable and Addressable) which contain the arrays of columns to require and fields to validate against. One I am working on right now is 'Addressable'

module Addressable 
  extend ActiveSupport::Concern
  ERROR = 'please ensure necessary fields are in place'

  REQUIRED_DATABASE_FIELDS = %w{address1 
                                address2 
                                address3 
                                town 
                                county 
                                country 
                                postcode}

  REQUIRED_INPUT_FIELDS = %w{address1 postcode}

  included do
    REQUIRED_DATABASE_FIELDS.map { |rdf| raise "#{rdf} not included. #{ERROR}" unless column_names.include?(rdf)}
    REQUIRED_INPUT_FIELDS.map { |rif| validates rif.to_sym, presence: true}
  end
end

Obviously here there is duplication. However, if I include this module into contactable I avoid the need for some of the repetition but it means that Contactable will always include Phoneable and Emailable also. In some cases I might not want to validate or require these traits. Is there a way I can achieve this flexibility?

rico_mac
  • 888
  • 8
  • 22

2 Answers2

1

You can do something like this:

Add /app/models/concerns/fields_validator.rb

module FieldsValidator
  extend ActiveSupport::Concern

  class_methods do
    def validate_required_attributes
      required_attributes.each do |a|
        puts "adds validation for #{a}"
        validates(a.to_sym, presence: true)
      end
    end

    def load_required_attributes(*_attrs)
      puts "loading attrs: #{_attrs.to_s}"
      @required_attributes ||=[]
      @required_attributes += _attrs
      @required_attributes.uniq!
    end

    def required_attributes
      @required_attributes
    end
  end
end

Add /app/models/concerns/contact.rb

module Contact
  extend ActiveSupport::Concern
  include FieldsValidator

  included do
    puts "include contact..."
    load_required_attributes(:product_details, :observations, :offer_details)
  end
end

Add /app/models/concerns/address.rb

module Address
  extend ActiveSupport::Concern
  include FieldsValidator

  included do
    puts "include address..."
    load_required_attributes(:sku, :amount, :observations)
  end
end

In the model...

class Promotion < ActiveRecord::Base
  include Address
  include Contact

  validate_required_attributes
end

The output:

include address...
loading attrs: [:sku, :amount, :observations]
include contact...
loading attrs: [:product_details, :observations, :offer_details]
adds validation for sku
adds validation for amount
adds validation for observations
adds validation for product_details
adds validation for offer_details

To check this is working...

Promotion.new.save!
"ActiveRecord::RecordInvalid: Validation failed: Sku can't be blank, Amount can't be blank, Observations can't be blank, Product details can't be blank, Offer details can't be blank"

Considerations:

  • keep your modules inside a custom namespace. You will have problems with the existent Addressable module. For example:

    module MyApp
      module Addressable
      # code...
      end
    end
    
    class Promotion < ActiveRecord::Base
      include MyApp::Addressable
    
      validate_required_attributes
    end
    
  • You need to load all the attributes first and then apply the validations. If you don`t do that, you could repeat validations if modules share attributes.

  • the shared logic goes in FieldsValidator module
Leantraxxx
  • 4,506
  • 3
  • 38
  • 56
  • Your first consideration, can you explain what issues I might have and how would it be namspaced? If I could give this more points I would. – rico_mac Jul 23 '15 at 21:18
  • I've update my answer. You only need to wrap your modules into another module (`MyApp`) por example. Then, you need tu include the modules using the namespace. You can do `include MyApp::Addressable` instead `include Address` – Leantraxxx Jul 23 '15 at 21:30
0

You should be using unit tests here instead. You not really achieving anything by checking the database schema from your models (or the modules they include). If the columns are not present you application will throw a NoMethodError or a database driver error anyways.

Its better to actually have unit tests which cover your models and ensure they work as expected.

require 'rails_helper'

describe User
  # Tests the presence of the database column indirectly. 
  it { should respond_to :email }

  # Explicit test - there a very few good reasons to actually do this.
  it "should have the email column" do
    expect(User.column_names).to have_key :email 
  end
end

If you are using RSpec you can use shared examples to reduce the amount of duplication in your specs.

# support/example_groups/addressable.rb
require 'spec_helper'
RSpec.shared_examples_for "an addressable" do
  it { should respond_to :address1 }
  it { should respond_to :address2 }
  it { should respond_to :address3 } 
  it { should respond_to :county } 
  it { should respond_to :postcode } 
  # ...
end

require 'rails_helper'
require 'support/example_groups/addressable'

describe User
  it_should_behave_like "an addressable"
end

See How do i get RSpec's shared examples like behavior in Ruby Test::Unit? for examples of how to acheive the same thing with test_unit / minitest.

Community
  • 1
  • 1
max
  • 96,212
  • 14
  • 104
  • 165