Noncanonical STI in Rails

    Before you start the story, remember what is STI .

    STI (Single Table Inheritance) is a design pattern that allows you to transfer object-oriented inheritance to a relational database table. The database table must contain a field identifying the name of the class in the hierarchy. Often, including in RoR, the field is called type.

    Using this pattern, you can create objects that contain an identical set of fields, but have different behaviors. For example, a user table containing a name, username and password, but two classes of Admin users were used, Visitor. Each class contains both inherited and an individual set of methods. Determining which class will be created and using the type field ,field name can be redefined.

    Thus, if we consider the canonical case: class names are stored in one table with data.

    But a different situation may happen ...

    There are tasks when it is necessary on top of an existing database, on which a certain web editor is already tied to a lot. And the likelihood that the existing circuit will fully meet the requirements of ORM is small. As a result, you have to pull up the whole thing when configuring the models.

    A fairly common practice, for normalization, is the use of lookup tables.

    For example, it could be a contact table associated with a directory of contact types. In this case, it would be logical to check the entered data at the model level, you can add methods for formatting values, and so on.

    There are two ways to solve this problem:
    1. use STI , it directly begs here;
    2. use one thick class, in which the logic is defined through case .

    I don’t even consider the second option, because is too bulky and not too flexible. Therefore, we dwell on the first.

    And so, to use STI , an additional field is needed that will point to the class. It is possible to redo the scheme, but redundancy increases, which must be maintained in the correct state. In the case of the above example, when adding the type field , you will have to synchronize the value of the field with the foreign key. Therefore, it would be logical to use the available data. Because If the class name is determined before it is created, you will have to intervene in the work of ActiveRecord itself .

    Digging in the documentation and sources clarified this whole mechanism. The instantiate method is responsible for it.located in the ActiveRecord :: Inheritance module :
    # File activerecord/lib/active_record/inheritance.rb, line 61
    def instantiate(record)
      sti_class = find_sti_class(record[inheritance_column])
      record_id = sti_class.primary_key && record[sti_class.primary_key]
      if ActiveRecord::IdentityMap.enabled? && record_id
        instance = use_identity_map(sti_class, record_id, record)
      else
        instance = sti_class.allocate.init_with('attributes' => record)
      end
      instance
    end

    This method is quite simple:
    1. The class to be created is defined.
    2. if IdentityMap support is enabled, then we use it, otherwise we will create a new instance based on the data received from the database.

    Let's consider how it is determined which class should be created. To do this, look at the source code further, namely, the find_sti_class method , into which the name of the type taken from the corresponding inheritance_column field is transferred , by default, as mentioned above, it is equal to type.

    As you can see, there is no special magic. Therefore, to solve the task, it was necessary to redefine the instantiate method so that instead of the value from the field, another one received from the linked table was transferred.

    The resulting solution was framed in the form of Gem-a. It works on the same principle as associations. ActiveRecord extends with an additional acts_as_ati method , which has the same syntax as the methodbelongs_to .

    @association_inheritance = {
      id: 0,
      field_name: params[:field_name] || :name,
      block: block_given? ? Proc.new {|type| yield type } : Proc.new{ |type| type },
      class_cache: {},
      alias: {}
    }      
    params.delete :field_name
    @association_inheritance[:association] = belongs_to(association_name, params)
    validates @association_inheritance[:association].foreign_key.to_sym, :presence => true
    before_validation :init_type

    In this method, a hash is formed with auxiliary information on communication, the relation itself and validators are also added. In addition, the instance is expanded with a number of helper methods + overload is actually performed.

    The overloaded method basically does not change; only the receipt of the class name based on the created relationship is added.

    params = self.association_inheritance          
    class_type =  if record.is_a? String
      (params[:alias][record.to_s.downcase.to_sym] || record).to_s.classify                        
    else
      association = params[:association]            
      type_id = record[association.foreign_key.to_s]
      params[:class_cache][type_id] ||= begin
        inheritance_record = association.klass.find(type_id)       
        value = inheritance_record.send(params[:field_name].to_sym)     
        value = (params[:alias][value.to_s.downcase.to_sym] || value)              
        value.to_s.classify
      rescue ::ActiveRecord::RecordNotFound
        ''
      end
    end
    sti_class = find_sti_class(params[:block].call(class_type))

    This is where the main changes end. Using the resulting class, it turned out to implement STI through a linked table. This approach has a minus in performance (it was decided in some places by data caching), but at the same time it makes it possible to fully use polymorphism.

    Usage example:
    class PostType < ActiveRecord::Base
    end
    class Post < ActiveRecord::Base
        attr_accessible :name
        acts_as_ati :type, :class_name => PostType,
            :foreign_key => :post_type_id,
            :field_name => :name do |type|       
            "#{type}Post"
        end    
    end
    class ForumPost < Post
        attr_accessible :name    
        ati_type :forum
    end
    class BlogPost < Post
        attr_accessible :name  
        ati_type :blog
    end

    This solution is used in the work of the internal resource and so far has shown itself only on the positive side and has made it possible to make a code that is more readable and easy to support.

    Gem has not yet been hosted on rubygems, but it can be connected via Gemfile:
    gem 'ext_sti', :git => 'git://github.com/fuCtor/ext_sti.git'
    either as a local copy
    gem 'ext_sti', :path => %path_to_ext_sti%

    Also popular now: