Running with Ruby

Tag: Concern

ActiveRecord isolated, namespaced scopes and methods using instance_exec and ActiveSupport::Concern

Rails concerns are a really good way to provide shared functionalities into your models. For example you can add scopes and then apply them in multiple classes:

module Statistics
  extend ActiveSupport::Concern

  included do
    scope :disabled, -> { where(disabled: true) }
  end
end

class Host
  include Statistics
end

One of the downsides of this solution is a “models pollution”. Every model in which we want to include our module, will get all the methods and scopes from that module. With multiple concerns and extensions, it can create issues with methods and scopes overwriting each other. But as always with Ruby, there’s a quite nice solution that will allow you to define custom scopes that will be encapsulated in a namespace method. This approach allows to have multiple scopes with the same name that will vary depending on an invocation context:

module Statistics
  extend ActiveSupport::Concern

  included do
    # Some magic here...
    scope :last_week, -> { where('created_at >= ?', 1.week.ago) }
  end
end

module Calculable
  extend ActiveSupport::Concern

  included do
    # Some magic here...
    scope :last_week, -> { unscoped.where('created_at >= ?', 1.week.ago) }
  end
end

class Host
  include Statistics
  include Calculable
end

# last_week accessible via statistics namespace
Host.statistics.last_week #=> all the hosts created last week
# last_week not available directly on a host class
Host.last_week #=> This will raise NoMethodError: undefined method last_week
# last_week should be also chainable with other scopes
Host.enabled.statistics.last_week #=> all enabled hosts from last week
# And the last_week method but from the calculable namespace
Host.calculable.last_week

Proxy object and instance_exec as a solution

To obtain such a behavior we can use a proxy object in our concern module:

module Statisticable
  extend ActiveSupport::Concern

  # Class that acts as a simple proxy for all statistical scopes
  # We have it, because we don't want to "pollute" original klass
  # that we extend with this. This way we can define scopes here
  # and then access them via .statistics class method of a class
  class Proxy
    # @param [Symbol] name of a scope or a method
    # @param [Block] block with a scope that will be binded to the class
    def self.scope(name, block)
      define_method(name) do |*args|
        @scope.instance_exec(*args, &block)
      end
    end

    # @param scope [ActiveRecord::Base] active record class for which we want to
    #   create statistics proxy
    def initialize(scope)
      @scope = scope
      # Here we could also use any scope that we want, like:
      # @scope = scope.unscoped
    end

    %i( week month year ).each do |time|
      # @return [Integer] number of elements that were created in a certain time
      scope :"last_#{time}", -> { where('created_at >= ?', 1.send(time).ago) }
    end

    # We can add any standard method as well - but it can't be chained further
    scope :summed, -> { sum(:id) }
  end

  included do
    # @return [Statisticable::Proxy] proxy that provides us with scopes for statistical info
    #   about a given class
    # @example Get statistics proxy for Like class
    #   Like.statistics #=> Statisticable::Proxy instance
    def self.statistics
      @statistics ||= Statisticable::Proxy.new(self)
    end
  end
end

It uses the instance_exec method to execute all the scopes in our base class (@scope) context.

Now in every AR in which we include this Statisticable concern, all its scopes and methods will be available via statistics namespace.

Paperclip, Bootstrap and SimpleForm working together on Rails

Few days ago, I’ve decided to get back to the “original” Paperclip. Until now, in one of my projects I’ve been using forked version with additional tweaks. However, supporting it for 3 years was enough. Getting back on track was fairly simple. It took me 1 day to fix the file structure, next day to rewrite validators and that would be all except one thing. Paperclip attaches error to 3 fields (example for thumb file):

  • thumb_file_name
  • thumb_file_size
  • thumb_content_type

I don’t like this idea, since those are basically the internals of Paperclip implementation and in my opinion, all the errors should be attached to the “base” attribute (which in this case is called thumb). Furthermore this is not only the architectural problem but it also affect my views. I use Bootstrap with a SimpleForm attached to it and adding Paperclip to it seams fairly simply:

= simple_form_for @user, :html => { :class => 'form-horizontal' } do |f|
  = f.input :avatar

And… this should be it. However, as I mentioned above, Paperclip attaches errors to fields different than “thumb” (not all of them but it doesn’t matter), so they aren’t displayed on the interface. I could use something like that:

= f.errors :thumb_content_type

and well, this indeed works, unfortunately without any Bootstrap stylings. Also it requires extra line for each attachment that I use. So it sucks! That’s why I’ve decided to do a little hack on it: let’s just copy all the error messages into original “base” attachment name. This should solve our problem (and it did):

# lib/paperclip_extensions.rb
module PaperclipExtensions

  extend ActiveSupport::Concern

  module ClassMethods
    # Changes the default Paperclip behaviour so all the errors from attachments
    # are assigned to an attachment name field instead of 4 different once
    # This allows us to use Bootstrap and SimpleForm without any other extra
    # code
    def has_attached_file(name, options = {})
      # Initialize Paperclip stuff
      super
      # Then create a hookup to rewrite all the errors after validation
      after_validation do
        self.errors[name] ||= []
        %w{file_name file_size content_type updated_at}.each do |field|
          field_errors = self.errors["#{name}_#{field}"]
          next if field_errors.blank?

          self.errors[name] += field_errors
          field_errors.clear
        end
        self.errors[name].flatten!
      end
    end
  end

end

ActiveRecord::Base.send(:include, PaperclipExtensions)

Just put this code into a file in lib/ and create an initializer where you will require it:

# config/initializers/paperclip_extensions.rb
require 'paperclip_extensions'

After that, you are ready to go :) Enjoy using Paperclip with Bootstrap and SimpleForm without any problems!

Copyright © 2018 Running with Ruby

Theme by Anders NorenUp ↑