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

в 10:14, , рубрики: ror 3, ruby, ruby on rails, STI, метки: , , ,

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

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

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

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

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

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

Для решения этой задачи есть два пути:

  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%

Автор: fuCtor

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js