タケユー・ウェブ日報

Ruby on Rails や Flutter といったWeb・モバイルアプリ技術を武器にお客様のビジネス立ち上げを支援する、タケユー・ウェブ株式会社の技術ブログです。

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による条件組み立て、条件のマージなど馴染み深い方法で行えるようになります。