FrontPage  Index  Search  Changes  RSS  Login

[Ruby] Rails の STI について調査

概要

ActiveRecord の STI(Single Table Inheritance) について、実装を追いかけた記録です。

以降、Rails のバージョンは 2008/09/24 現在で最新の2.1.1です。

ワンセット欲しかったので、GitHubからコードを持ってきました。

$ git clone git://github.com/rails/rails.git
$ cd rails
$ git fetch --tags
$ git git checkout v2.1.1
$ ctags -e --recurse --languages=ruby

※ 最後の ctags は気にしないでください。

途中、基本的な事も記録していますが、そこは見逃してください。

※ 2008/09/25 現在、中途半端な状態で公開中です。

とっかかりは`ack STI`

$ ack STI activerecord/lib

rails/activerecord/lib/active_record/base.rb:469

ActiveRecord::Base の469行目に、STIに関するコメントとコードを発見。

# Determine whether to store the full constant name including namespace when using STI
# -> STI 利用時に、ネームスペースを含む完全な定数名を格納するかどうか判断する
superclass_delegating_accessor :store_full_sti_class
self.store_full_sti_class = false

ActiveRecord::Base.store_full_sti_class によって、STI の具象クラスの名前をネームスペース込みな完全形にして保持するかどうかが決定される様子。

rails/activesupport/lib/active_support/core_ext/class/delegating_attributes.rb

Class#superclass_delegating_reader(*names)

superclass_delegating_accessor は、指定した名前のインスタンス変数へのアクセスを、クラスの継承階層を遡りつつ行うようにしてくれるメソッド。

notes
  • クラスの比較は継承関係によって判断される(`refe2 Class \>`)
  • クラスのnameメソッドは、クラス名を文字列で返す
  • !!の様に否定の重ねがけで値を論理値にキャストしたりする

rails/activerecord/lib/active_record/base.rb:1015

ActiveRecord::Base の1015行目に、STIに関するコメントとコードを発見。

def reset_table_name #:nodoc:
  base = base_class

  name =
    # STI subclasses always use their superclass' table.
    # -> STI サブクラスは常に自身のスーパークラスのテーブルを利用する
    unless self == base
      base.table_name
    else
      # Nested classes are prefixed with singular parent table name.
      if parent < ActiveRecord::Base && !parent.abstract_class?
        contained = parent.table_name
        contained = contained.singularize if parent.pluralize_table_names
        contained << '_'
      end
      name = "#{table_name_prefix}#{contained}#{undecorated_table_name(base.name)}#{table_name_suffix}"
    end

  set_table_name(name)
  name
end

reset_table_name は、STI 固有のメソッドではないはず。

ActiveRecord::Base#base_class
def base_class
  class_of_active_record_descendant(self)
end
ActiveRecord::Base#class_of_active_record_descendant
def class_of_active_record_descendant(klass)
  if klass.superclass == Base || klass.superclass.abstract_class?
    klass
  elsif klass.superclass.nil?
    raise ActiveRecordError, "#{name} doesn't belong in a hierarchy descending from ActiveRecord"
  else
    class_of_active_record_descendant(klass.superclass)
  end
end
ActiveRecord::Base#abstract_class?
...
attr_accessor :abstract_class
...
def abstract_class?
  defined?(@abstract_class) && @abstract_class == true
end

モデルクラスが抽象クラスである(クラス名かインスタンス変数abstract_classの値で判断)なら、そのクラスのtable_nameメソッドを利用してテーブル名を取得している。つまり、コメント通りに、STI スーパークラス(抽象クラス)のテーブルを利用するという事。

インスタンス変数abstract_classを、どのタイミングでtrueにしているのか。探せないので、保留(TODO)。

rails/activerecord/lib/active_record/base.rb:1202

ActiveRecord::Base の1202行目に、STIに関するコメントとコードを発見。

# True if this isn't a concrete subclass needing a STI type condition.
# -> もしも STI type condition が必要な具象サブクラスでないなら True
def descends_from_active_record?
  if superclass.abstract_class?
    superclass.descends_from_active_record?
  else
    superclass == Base || !columns_hash.include?(inheritance_column)
  end
end
ActiveRecord::Base#inheritance_column
# Defines the column name for use with single table inheritance
# -- can be set in subclasses like so: self.inheritance_column = "type_id"
def inheritance_column
  @inheritance_column ||= "type".freeze
end

inheritance_columnメソッドで、 STI で利用する カラム名を定義できる様子。

STI の抽象クラスであれば、abstract_class?メソッドが真となり、結果としてdescends_from_active_record?は真となる。一方で、STI の具象クラスであれば、当然カラムにinheritance_columnメソッドで得られる名前のカラムが存在しているのでdescends_from_active_record?は偽となる。

小文字の"sti_"も忘れずに

"sti_"で検索(1)

ActiveRecord::Base#sti_name
def sti_name
  store_full_sti_class ? name : name.demodulize
end

先述のstore_full_sti_classに従って、クラス名を返す。

rails/activesupport/lib/active_support/core_ext/string/inflections.rb

# Removes the module part from the constant expression in the string.
#
#   "ActiveRecord::CoreExtensions::String::Inflections".demodulize # => "Inflections"
#   "Inflections".demodulize                                       # => "Inflections"
def demodulize
  Inflector.demodulize(self)
end

rails/activesupport/lib/active_support/inflector.rb

# Removes the module part from the expression in the string.
#
# Examples:
#   "ActiveRecord::CoreExtensions::String::Inflections".demodulize # => "Inflections"
#   "Inflections".demodulize                                       # => "Inflections"
def demodulize(class_name_in_module)
  class_name_in_module.to_s.gsub(/^.*::/, '')
end

"sti_"で検索(2)

ActiveRecord::Base#type_name_with_module(type_name)
# Nest the type name in the same module as this class.
# Bar is "MyApp::Business::Bar" relative to MyApp::Business::Foo
def type_name_with_module(type_name)
  if store_full_sti_class
    type_name
  else
    (/^::/ =~ type_name) ? type_name : "#{parent.name}::#{type_name}"
  end
end
ActiveRecord::Base#compute_type(type_name)
# Returns the class type of the record using the current module as a prefix. So descendents of
# MyApp::Business::Account would appear as MyApp::Business::AccountSubclass.
def compute_type(type_name)
  modularized_name = type_name_with_module(type_name)
  begin
    class_eval(modularized_name, __FILE__, __LINE__)
  rescue NameError
    class_eval(type_name, __FILE__, __LINE__)
  end
end
ActiveRecord::Base#instantiate(record)
# Finder methods must instantiate through this method to work with the
# single-table inheritance model that makes it possible to create
# objects of different types from the same table.
# -> ファインダメソッドは、同一テーブルから異なるtypeの
# -> オブジェクトを作成する事を可能にするためのSTI モデルを
# -> 扱うために、このメソッドを通じてインスタンス化しなければならない
def instantiate(record)
  object =
    if subclass_name = record[inheritance_column]
      # No type given.
      if subclass_name.empty?
        allocate

      else
        # Ignore type if no column is present since it was probably
        # pulled in from a sloppy join.
        unless columns_hash.include?(inheritance_column)
          allocate

        else
          begin
            compute_type(subclass_name).allocate
          rescue NameError
            raise SubclassNotFound,
              "The single-table inheritance mechanism failed to locate the subclass: '#{record[inheritance_column]}'. " +
              "This error is raised because the column '#{inheritance_column}' is reserved for storing the class in case of inheritance. " +
              "Please rename this column if you didn't intend it to be used for storing the inheritance class " +
              "or overwrite #{self.to_s}.inheritance_column to use another column for that information."
          end
        end
      end
    else
      allocate
    end

  object.instance_variable_set("@attributes", record)
  object.instance_variable_set("@attributes_cache", Hash.new)

  if object.respond_to_without_attributes?(:after_find)
    object.send(:callback, :after_find)
  end

  if object.respond_to_without_attributes?(:after_initialize)
    object.send(:callback, :after_initialize)
  end

  object
end

レコードからinheritance_columnを取り出し、STI 具象クラス名が得られたなら、そのクラスのインスタンスをallocateメソッドで生成する、らしい。

notes
  • Class#allocateは、initializeを通らずに、単純にインスタンスを生成するだけのメソッド

"sti_"で検索(3)

ActiveRecord::Base#type_condition(table_alias=nil)
def type_condition(table_alias=nil)
  quoted_table_alias = self.connection.quote_table_name(table_alias || table_name)
  quoted_inheritance_column = connection.quote_column_name(inheritance_column)
  type_condition = subclasses.inject("#{quoted_table_alias}.#{quoted_inheritance_column} = '#{sti_name}' ") do |condition, subclass|
    condition << "OR #{quoted_table_alias}.#{quoted_inheritance_column} = '#{subclass.sti_name}' "
  end

  " (#{type_condition}) "
end
ActiveRecord::Base#subclasses
def subclasses #:nodoc:
  @@subclasses[self] ||= []
  @@subclasses[self] + extra = @@subclasses[self].inject([]) {|list, subclass| list + subclass.subclasses }
end
notes
001:>> x = 100
=> 100
002:>> x + y = 300
=> 400

subclassesメソッドが呼ばれるたびに、クラス変数subclasses[self]と、その配列の全要素のsubclassesを(再起呼び出しして)足し合わせている。extraへの代入は、再起呼び出しのためのトリックだろうか?深追いはしない。

ActiveRecord::Base.inherited(child)
def self.inherited(child) #:nodoc:
  @@subclasses[self] ||= []
  @@subclasses[self] << child
  super
end
ActiveRecord::Base.reset_subclasses
def self.reset_subclasses #:nodoc:
  nonreloadables = []
  subclasses.each do |klass|
    unless ActiveSupport::Dependencies.autoloaded? klass
      nonreloadables << klass
      next
    end
    klass.instance_variables.each { |var| klass.send(:remove_instance_variable, var) }
    klass.instance_methods(false).each { |m| klass.send :undef_method, m }
  end
  @@subclasses = {}
  nonreloadables.each { |klass| (@@subclasses[klass.superclass] ||= []) << klass }
end

クラス変数subclassesは、クラスメソッドであるinheritedreset_subclassesが呼ばれると更新される。

notes
  • Class#inherited(subclass)はサブクラスが定義された時点で実行される
    • Ruby のリファレンス参照

type_conditionメソッドは、実行時点でロード(評価)済みな一連の STI モデル群に関して、SQL の条件句を生成するメソッド。

$ rails sti
$ cd sti
$ sqlite3 -batch db/development.sqlite3 'CREATE TABLE samples (id integer not null primary key, type varchar(255))'
$ script/console
001:>> class Sample < ActiveRecord::Base; end
=> nil
002:>> class ChildA < Sample; end
=> nil
003:>> class ChildB < Sample; end
=> nil
004:>> a = ChildA.create
=> #<ChildA id: 1, type: "ChildA">
005:>> Sample.count
=> 1
006:>> ChildA.count
=> 1
007:>> ChildB.count
=> 0
008:>> b = ChildB.create
=> #<ChildB id: 2, type: "ChildB">
009:>> Sample.count
=> 2
010:>> ChildA.count
=> 1
011:>> ChildB.count
=> 1
012:>> class ChildAA < ChildA; end
=> nil
013:>> ChildAA.count 
=> 0
014:>> ChildAA.create
=> #<ChildAA id: 3, type: "ChildAA">
015:>> Sample.count
=> 3
016:>> ChildA.count
=> 2
017:>> ChildB.count
=> 1
018:>> ChildAA.count
=> 1

後は、関係各所でfinder_needs_type_condition?メソッドなどを利用した条件判定とあわせ、必要に応じてtype_conditionで生成されるSQL条件句が利用されるはず。

Last modified:2008/09/25 01:53:58
Keyword(s):[ruby]
References: