Неканоническое STI в Rails

    Перед тем как начать повествование, вспомним что же такое STI.

    STI (Single Table Inheritance) — паттерн проектирования, который позволяет перенести объектно-ориентированное наследование на таблицу реляционной базы данных. В таблице БД должно присутствовать поле идентифицирующее название класса в иерархии. Зачастую, в том числе в RoR, поле называют type.

    С помощью данного паттерна можно создавать объекты, которые содержат идентичный набор полей, но имеют разное поведение. Например, таблица пользователей, содержащая имя, логин и пароль, но использовалось два класс пользователей Admin, Visitor. Каждый класс, содержит как унаследованны так и индивидуальный набор методов. Определение того, какой класс будет создан и используется поле type, имя поля может быть переопределено.

    Таким образом, если рассматривать канонический случай: имена классов хранятся в одной таблице с данными.

    Но может случится иная ситуация…

    Бывают задачи, когда необходимо поверх существующей базы, на которую уже много чего завязано сделать некоторый web-редактор. И вероятность того, что имеющаяся схема будет полностью удовлетворять требованиям ORM мала. Как следствие приходится, конфигурируя модели, натягивать все это дело.

    Довольно распространенной практикой, для нормализации, является использование справочных таблиц.

    Например это может быть таблица контактов связанная со справочником типов контактов. При этом было бы логично сделать проверку введенных данных на уровне модели, можно добавить методы для форматирования значений и так далее.

    Для решения этой задачи есть два пути:
    1. воспользоваться STI, он прямо напрашивается сюда;
    2. использовать один толстый класс, в котором логику определять через case.

    Второй вариант даже не рассматриваю, т.к. является слишком громоздким и не слишком гибким. Поэтому остановимся на первом.

    И так, для использования STI необходимо дополнительное поле, которое будет указывать на класс. Переделывать схему можно, но возрастает избыточность, которую нужно поддерживать в корректном состоянии. В случае приведенного примера, при добавлении поля type, придется значение поля синхронизировать с внешним ключом. Поэтому было бы логично воспользоваться имеющимися данными. Т.к. определение имени класса происходит еще до его создания, то вмешиваться придется в работу самого ActiveRecord.

    Копания в документации и исходниках прояснили весь этот механизм. За него отвечает метод instantiate находящийся в модуле ActiveRecord::Inheritance:
    # 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

    Данный метод достаточно прост:
    1. определяется класс который должен быть создан;
    2. если включена поддержка IdentityMap, то используем ее, иначе формируем новый экземпляр на основании полученных из базы данных.

    Рассмотрим как же определяется какой класс должен быть создан. Для этого смотрим исходники дальше, а именно метод find_sti_class, в который передается имя типа взятого из поля соответствующего inheritance_column, по-умолчанию, как уже было сказано ранее, оно равно type.

    Как видите нет какой-то особой магии. Поэтому для решения поставленной задачи было необходимо переопределить метод instantiate, чтобы вместо значения из поля передавалось другое, полученное из связанной таблицы.

    Полученное решение было оформленно в виде Gem-a. Работает по такому же принципу как и ассоциации. ActiveRecord расширяет дополнительным методом acts_as_ati, который имеет такой же синтаксис как и метод belongs_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

    В данном методе формируется хэш со вспомогательной информацией по связи, также добавляется собственно само отношение и валидаторы. Кроме того экземпляр расширяется рядом вспомогательных методов + собственно выполняется перегрузка.

    Перегруженный метод в основе своей не меняется, добавляется лишь получение на основании созданного отношения имени класса.

    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))

    На этом основные изменения заканчиваются. Используя полученный класс получилось реализовать STI через связанную таблицу. Данный подход имеет минус по производительности (местами решено кэшированием данных), но при этом дает возможность в полной мере использовать полиморфизм.

    Пример использования:
    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

    Данное решение применяется в работе внутреннего ресурса и пока показало себя только с положительной стороны и позволило сделать более читабельный и простой в поддержке код.

    Gem пока еще не размещен на rubygems, но его можно подключить через Gemfile:
    gem 'ext_sti', :git => 'git://github.com/fuCtor/ext_sti.git'
    либо как локальную копию
    gem 'ext_sti', :path => %path_to_ext_sti%
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 9

      0
      Чем нарисованы такие прекрасные картинки?
        0
        dbdsgnr.appspot.com/app вот тут рисовал, а потом Ножницы в Windows.
          0
          Если уж говорить про инструменты, то есть такая замечательная штука как Power Architect. Основная прелесть в том что она умеет делать reverse
          +1
          >> Переделывать схему можно, но возрастает избыточность, которую нужно поддерживать в корректном состоянии.

          Не совсем понял, почему возрастает избыточность? С точки зрения БД вроде все в рамках третьей нормальной формы находится.
            0
            Получается такая ситуация (по второму рисунку): появляется дополнительное поле type, поле type_id никуда не пропадает. Получается что два поля идентифицируют сущность. Если тип контакта изменится, то нужно будет изменить и имя класса.

            Но основное условие было постараться максимально меньше внести изменений в существующую структуру.
              0
              Может, стоило бы поле type поместить в ContactType, а не в Contact?
                0
                А зачем? Там есть поле name, на основе которого можно вычислить класс.
                  0
                  Для чего тогда вообще нужно поле type? Только чтобы при выборке из таблицы Contact не джоинить с ContactType?
                    0
                    Я б сформулировал иначе: чтобы использовать штатную реализацию STI. Поле может называться как угодно, но оно обязано указывать на существующий класс, либо быть пустым и находится в тойже таблице. В моем случае данное поле заменяю на join с другой таблицей и получение имени класса оттуда. Штатными средствами реализовать не получилось такую схему.

          Only users with full accounts can post comments. Log in, please.