How to DRYit models

    In most rail projects, the main concentration of code falls on the model. Everyone probably read about Slim controllers & fat models and try to shove as much as possible into models, and as little as possible into controllers. Well, this is commendable, however, in an effort to thicken models, many often forget about the DRY principle - don't (fucking) repeat yourself.

    I’ll try to briefly describe how to eat fish in the area of ​​models, and not to forget about DRY.



    So, the model we have is a cut class. Like any cut class, a model consists of three things:

    1) definition of instance methods

    class User < ActiveRecord::Base
      def name
        [ first_name, last_name ].filter(&:presence).compact.map(&:strip) * ' '
      end
    end
    


    2) class method definitions

    class User < ActiveRecord::Base
      def self.find_old
        where('created_at < ?', 1.year.ago).all
      end
    end
    


    3) "class" code

    class User < ActiveRecord::Base
      attr_accessor :foo
      has_many :authentications, :dependent => :destroy
      has_many :invitees, :class_name => 'User', :as => :invited_by
      delegate :city, :country, :to => :location
      attr_accessible :admin, :banned, :as => :admin
      mount_uploader :userpic, UserPicUploader
      scope :admin, where(:admin => true)
      validates :username,
                :uniqueness => true,
                :format => /^[a-z][a-z\d_-]+$/,
                :length => { :within => 3..20 },
                :exclusion => { :in => USERNAME_EXCLUSION }
    end
    


    The easiest way to reprogram a model, like, in fact, any other class in ruby, is to take out the repeating parts in separate modules. In fact, modules are not only useful for this. Sometimes it’s not bad, it’s just putting a huge trash of code into a separate file so that the model looks cleaner and tons of irrelevant garbage do not get under your feet.

    Methods


    So, with the definitions of instance methods, everything is as simple and clear as possible:
    # user_stuff.rb
    module UserStuff
      def name
        [ first_name, last_name ].filter(&:presence).compact.map(&:strip) * ' '
      end
    end
    # user.rb
    class User < ActiveRecord::Base
      include UserStuff
    end
    


    Class methods are slightly more interesting. This doesn’t work:
    # user_stuff.rb
    module UserStuff
      def self.find_old
        where('created_at < ?', 1.year.ago).all
      end
    end
    # user.rb
    class User < ActiveRecord::Base
      include UserStuff
    end
    

    Here we define the method for find_oldthe module itself UserStuff, but it will not end up in the model.
    Therefore, you need to do something like this:
    # user_stuff.rb
    module UserStuff
      def find_old
        where('created_at < ?', 1.year.ago).all
      end
    end
    # user.rb
    class User < ActiveRecord::Base
      extend UserStuff # внимание, не include, а extend
    end
    


    Together, intans and class methods can be put into a module, for example, like this:
    # user_stuff.rb
    module UserStuff
      module InstanceMethods
        def name
          [ first_name, last_name ].filter(&:presence).compact.map(&:strip) * ' '
        end
      end
      module ClassMethods
        def find_old
          where('created_at < ?', 1.year.ago).all
        end
      end
    end
    # user.rb
    class User < ActiveRecord::Base
      include UserStuff::InstanceMethods
      extend UserStuff::ClassMethods
    end
    


    Class code


    The unresolved question is what to do with the "class" code. Usually he is just the most and he most often needs DRYing.

    The problem is that it must be executed in the context of the declared class (the model in our case). It is simply impossible to write it inside the module - it will try to execute immediately and most likely will break, because a simple module does not know anything about validation, nor about numerous plugins, nor about has_manyetc. Therefore, you need to put the code into the module so that it is executed only when this module is connected to the model. Fortunately, in ruby ​​this is very easy to do.

    The Module object has an included method, which is called every time we include the module somewhere. Thus, we can force the code we need to execute under this matter. Like this:
    module UserValidations
      def self.included(base)
        # base в данном случае — то, куда инклудится модуль.
        base.instance_eval do
          # выполняем код в контексте base
          # только в момент подключения модуля
          validates :username,
                    :uniqueness => true,
                    :format => /^[a-z][a-z\d_-]+$/,
                    :length => { :within => 3..20 },
                    :exclusion => { :in => USERNAME_EXCLUSION }
          validates :gender,
                    :allow_blank => true,
                    :inclusion => { :in => %w(m f) }
        end
      end
    end
    


    Now all the validation code will not be executed at the time the module is defined, but will lie quietly and wait until we get this module somewhere inactive.

    All in a bunch


    Now how would this be combined with the method definitions that were above? That's how:

    # user_stuff.rb
    module UserStuff
      def self.included(base)
        # экстендим модель методами класса
        base.extend ClassMethods
        # инклудим методы экземпляра
        # Module#include — приватный метод. Его по задумке можно вызывать только внутри определения класса
        # Поэтому мы не можем написать base.include(InstanceMethods), а приходится делать так:
        base.send :include, InstanceMethods
        # а дальше пошли валидации и прочее
        base.instance_eval do
          validates :gender, :presence => true
        end
      end
      # методы класса
      module ClassMethods
        def find_old
          where('created_at < ?', 1.year.ago).all
        end
      end
      # инстанс-методы
      module InstanceMethods
        def name
          [ first_name, last_name ].filter(&:presence).compact.map(&:strip) * ' '
        end
      end
    end
    # user.rb
    class User < ActiveRecord::Base
      include UserStuff
    end
    


    In total, we have a module in which you can safely take out any arbitrarily complex piece of the model, and then how many times you need to use this piece. Everything is pretty cool. It’s not cool that the code is ugly and you can easily get confused in these endless included / send: include / extend.

    You can be beautiful!


    In the ruby ​​community, the readability and beauty of the code is greatly appreciated, as well as the principle of hiding complexity - to hide complex things behind some simple and beautiful API / DSL. If you look at the RoR code, it immediately becomes clear that the above approach is used there almost everywhere. Therefore, of course, the guys decided to make their life easier and came up with ActiveSupport::Concern.

    Using this very ActiveSupport::Concerncode, we can rewrite it like this:
    module UserStuff
      extend ActiveSupport::Concern
      included do
        validates :gender, :presence => true
      end
      # методы класса
      module ClassMethods
        def find_old
          where('created_at < ?', 1.year.ago).all
        end
      end
      # инстанс-методы
      module InstanceMethods
        def name
          [ first_name, last_name ].filter(&:presence).compact.map(&:strip) * ' '
        end
      end
    end
    


    In fact, a more beautiful code is not the only feature of this helper. It still has some useful features, but I may someday write about them separately. Who can’t wait, let him go read the raw active_support in the area lib/active_support/concern.rb, there are very cool and weighty comments with code examples and all that.

    Where to put it?


    Another important point that the programmer will immediately encounter, who for the first time decides to tear his models into separate modules - where to put the code and how to name the modules?

    There probably will not be an unambiguous opinion, but for myself I came up with the following scheme:
    # lib/models/validations.rb
    module Models
      module Validations
        # общий модуль c валидациями для нескольких моделей
      end
    end
    # lib/models/user/validation.rb
    module Models
      module User
        module Validations
          # валидации для модели User
        end
      end
    end
    # app/models/user.rb
    class User < ActiveRecord::Base
      include Models::Validations
      include Models::User::Validations
    end
    


    File names matter because they allow you to not load all the code at once, and use rail autoload - a mechanism that when it encounters an undefined constant, such as Models::User::Validations, first tries to search for the file models/user/validations.rband try to download it, and only then, in case of failure, panic and throw an exception NameError.

    Conclusion


    I hope someone brings out something useful from the article for themselves and in the world it becomes a little less than a shitty-readable code and models for one and a half thousand time.

    Update:
    Here privaloff noticed that I was dumb in the code, which without concern, namely, I forgot about instance_eval. The code corrected. Comrade plus in karma for mindfulness.

    Also popular now: