元フリーエンジニアライフ

Ruby on Rails とか MovableType とかAWSやってるフリーランスウェブエンジニアの記録でした。現在は法人成りしてIT社長。

Ruby on Rails 4.2 のActiveRecordでMovable TypeのデータをQueryInterfaceとかArelとかで扱ってみた

この記事はMovable Type Advent Calendar 2015 - Adventar20日目の記事のはずでした。忘れてました。ごめんなさい。

ふと思い立って現段階の最新のRailsでMTのデータを扱う実験をしてみました。

Railsで非RailsなDBを扱うサンプルにもなるかも。

サンプルコード

takeyuweb/mtrails-example · GitHub

ActiveRecordを用いた例

検索

# 公開中で
# ウェブサイトの記事(blog_parent_id = null)で
# 記事本文にtarget_letterを含む
MT::Entry
  .release
  .where(blog: MT::Blog.where(blog_parent_id: nil))
  .where('entry_text like ?', "%#{target_letter}%")

# Blog ID=1の記事
MT::Entry.where(entry_blog_id: 1)
# Blogオブジェクトの関連でも
MT::Blog.find(1).entries
# さらに公開中で絞り込んでみたり
MT::Blog.find(1).entries.release

# 先頭の記事
MT::Entry.first

Arelを使ってカスタムフィールドdata1の値が100~200の記事を検索

entry_meta = MT::Entry::Meta.arel_table
cond1 = MT::Entry
  .joins(:meta)
  .where(entry_meta[:entry_meta_type].eq('field.data1')
           .and(entry_meta[:entry_meta_vchar_idx].in(100..200)))

# 「公開中」の条件とマージ
MT::Entry.release.merge(cond1)

データのマッピング

entry = MT::Entry.first
# 関連
entry.blog # => #<MT::Blog blog_id: 1, ...>
entry.category # => #<MT::Category category_id: 1, ...>
entry.categories # => #<ActiveRecord::Associations::CollectionProxy [#<MT::Category category_id: 1 ...>, ...]>
entry.author # => #<MT::Author author_id: 1, ...>
# カスタムフィールド
entry.meta # => #<ActiveRecord::Associations::CollectionProxy [#<MT::Entry::Meta entry_meta_entry_id: 12, entry_meta_type: "field.data1",  ...>, ...]>
entry.meta.pluck(:entry_meta_type) # => ["field.data1", "field.data2", "revision"]

# 属性値
entry.entry_title # => "TITLE"
entry.entry_status # => "release"

実装のポイント

他(MT)のDBへの接続

  • ActiveRecord::Base.establish_connection

今回は、config/initializers/mt.rbで設定し、MT::ObjectをincludeしたModelで適用されるようにしました。

https://github.com/takeyuweb/mtrails-example/blob/master/config/initializers/mt.rb

require_dependency 'mt/object'

Rails.application.config.to_prepare do
  MT::Object.connection_configuration = {
      adapter:  'mysql2',
      encoding: 'utf8',
      host:     'localhost',
      username: 'root',
      database: 'mt62',
      socket: '/var/lib/mysql/mysql.sock'
  }
end

※database.ymlに記載した設定を使うには、:mtのように指定すればOKです。

https://github.com/takeyuweb/mtrails-example/blob/master/app/models/mt/object.rb

require_dependency 'mt'

module MT::Object
  def self.connection_configuration=(spec)
    @connection_configuration = spec
  end

  def self.connection_configuration
    @connection_configuration
  end

  def self.included(klass)
    klass.class_eval do
      self.table_name_prefix = 'mt_'
      self.table_name = ['mt', name.split('::').last.underscore].join('_')
      self.primary_key_prefix_type = :table_name_with_underscore
      self.establish_connection ::MT::Object.connection_configuration
    end
  end
end

※MT::Objectを親クラスにして継承しないのは、ActiveRecordクラスの継承を行うと子クラスでtable_nameを設定しても、QueryInterfaceで親クラスのtable_nameが使われたりと、STI前提の感じでうまく動かなかったためです。

MTのテーブル名規約

  • ActiveRecord::Base.table_name=

includeされたときに、対象のクラス名からmt_entryのようなテーブル名を設定するようにしました。

MTの主キー名規約

  • ActiveRecord::Base.primary_key=
  • ActiveRecord::primary_key_prefix_type = :table_name_with_underscore

のどちらか。 includeされたときに、対象のクラス名からentry_idのような主キーを設定するようにしました。

記事の公開状態

MTではentry_statusの値により、記事の公開状態(公開、下書きなど)を決定します。

Rails 4.1以降であればenumが使えます。

https://github.com/takeyuweb/mtrails-example/blob/master/app/models/mt/entry.rb

  enum entry_status: {
      hold: 1,
      release: 2,
      review: 3,
      future: 4,
      junk: 5,
      unpublish: 6
  }

こうしておくことで、

entry.status # => "release"
entry.status = "review"

のようにステータスを人間に読みやすい形で取得・設定したり、

entry.repease! # => 公開で保存、エラーで例外送出

のようなメソッド

MT::Entry.release # => 公開中の記事を検索

のようなスコープがメタプログラミングにより自動的につくられます。

Rails標準でないbelongs_to

記事のブログはentry_blog_idでとれます。これを使って、ActiveRecordのbelongs_to関連を設定します。

  belongs_to :blog,
             class_name: 'MT::Blog',
             foreign_key: 'entry_blog_id'

foreign_keyで外部キー名を指定します。belongs_toの場合、自分のテーブルのカラムになります。

また、クラス名もRailsの規約からははずれているので、class_nameで設定しておきます。

entry.blog # => #<MT::Blog blog_id: 1, ...>

Rails標準でないhas_one,has_manyと多対多(has_many :through)

記事のカテゴリはmt_placementを中間テーブルとした多対多関連になります。

メインカテゴリについては中間テーブルのplacement_is_primaryを見ればOKです。

これをActiveRecordで設定するにはこんな感じになります。

  # メインカテゴリ
  has_one :placement,
          ->{ where(placement_is_primary: 1) },
          class_name: 'MT::Placement',
          foreign_key: 'placement_entry_id'
  has_one :category,
          class_name: 'MT::Category',
          through: :placement,
          source: :category

  # カテゴリ(メインカテゴリ含む)
  has_many :placements,
           class_name: 'MT::Placement',
           foreign_key: 'placement_entry_id'
  has_many :categories,
           class_name: 'MT::Category',
           through: :placements,
           source: :category

中間テーブルはforeign_keyで外部キー名を指定すると共に、has_many :throughの方で、中間テーブルのどの関連を使うかをsourceで指定します。

実際にsourceがどんな関連なのかは、MT::Placementで設定しています。

https://github.com/takeyuweb/mtrails-example/blob/master/app/models/mt/placement.rb

class MT::Placement < ActiveRecord::Base
  include MT::Object
  belongs_to :entry,
             class_name: 'MT::Entry',
             foreign_key: 'placement_entry_id'
  belongs_to :category,
             class_name: 'MT::Category',
             foreign_key: 'placement_category_id'
end

ActiveRecord Modelごとに存在するActiveRecord Model

MTのメタデータやカスタムフィールドのデータは、対象のテーブルごとに別々のテーブルやカラム名で存在します。(記事ならmt_entryに対するmt_entry_meta

個別にモデルクラスを作っても良いのですが、そうすると対応オブジェクトを増やすごとに重複コード増えていくので、メタプログラミングの絶好の機会です。

カスタムフィールドに対応するモデルクラスで、MT::Metaをincludeすると、Model名::Metaというモデルクラスを作り、必要な関連を設定するようにします。

https://github.com/takeyuweb/mtrails-example/blob/master/app/models/mt/entry.rb

class MT::Entry <ActiveRecord::Base
  include MT::Object
  include MT::Meta # <= (!)

MT::Metaはこのような具合です。

module MT::Meta
  def self.included(klass)
    meta_class = Class.new(ActiveRecord::Base)
    klass.const_set('Meta', meta_class)
    meta_class.table_name = [klass.table_name, 'meta'].join('_')
    meta_class.class_eval do
      establish_connection  ::MT::Object.connection_configuration
    end

    klass.class_eval do
      has_many :meta,
               class_name: [name, 'Meta'].join('::'),
               foreign_key: [name.split('::').last.underscore, 'meta', primary_key].join('_')
    end
  end
end

MT::EntryでMT::Metaをincludeすると、以下を機械的に行います。

  1. ActiveRecordモデルクラスMT::Entry::Metaをつくり
  2. そのテーブルとしてmt_entry_metaを設定
  3. MT::Entry.metaとしてメタデータの関連を設定

以上、かんたんにですが、とりあえずDBに接続して必要そうなデータを検索したりできるようになりました。 もっとも、実際に使えるものにするには、たとえばカスタムフィールドの検索をもっとわかりやすく書けるようにしたり、バリデーションを考えたり、ということが必要ですけれど・・・。

これで、僕たちRails使いが、MT::Objectに悩まされず、QueryInterfaceやArelによる条件組み立て、条件のマージなど馴染み深い方法で行えるようになります。