STI モデルの仕様(テスト)と実装の記述

モデルの設計が終わったら、実際にモデルクラスを記述します。STI に絡む部分は今までと若干異なったので、メモをしておきます。

モデルクラスおよびテーブルは Generator を使うことで、自動的に構築されます。

r g model device type:integer keyword:string device_type:integer j_title:string e_title:string date:date minutes:integer  seconds:integer sort_order:integer number:string jcomment:string ecomment:string year_id:integer singer_id:integer

この結果、migrate スクリプトが作成されます。検索高速化のために、必要に応じてインデックスを作成しておきます。サブクラスでの検索があるので type と sort_order の組でのインデックスを、履歴用に year_id と sort_order でのインデックスを作成しています。Rails 3.1 までは、up migration と down migration の二つのメソッドを書く必要がありましたが、3.2 からは Change 一つだけでよくなり、記述量が半減しています。

class CreateDevices < ActiveRecord::Migration
  def change
    create_table :devices do |t|
      t.string :type
      t.string :keyword
      t.integer :device_type
      t.string :j_title
      t.string :e_title
      t.date :date
      t.integer :minutes
      t.integer :seconds
      t.integer :sort_order
      t.string :number
      t.string :jcomment
      t.string :ecomment
      t.integer :year_id
      t.integer :singer_id
      t.timestamps
    end
    add_index :devices, :keyword
    add_index :devices, [:type, :sort_order]
    add_index :devices, [:year_id, :sort_order]
  end
end

migration して、テストテーブルも準備をしておきます。

rake db:migrate
rake db:test:prepare

Device 自体は抽象クラスのようなもので、積極的に使うことはほとんどないため、仕様(テスト)の記述はサブクラス側で行います。今回は例としてアルバムの仕様(rspec)を示します。spec/models/album_spec.rb は以下のようになります。create_delete_check は、昨年度ここに書いた日記("model 関連テストの手抜き法")に記述したものです。今回は削除を行わないのですが、念のためテストをしています。

# vim:set fileencoding=utf-8 filetype=ruby:
require 'spec_helper'

describe Album do
  fixtures :years, :individuals
  before :each do
    @relations = { Year:years(:year_1987), Individual:individuals(:individual_chisato_moritaka) }
  end

  create_delete_check Album,
    attributes:{ device_type:0, j_title:'def', keyword:'ghi', minutes:1, number:'1st', seconds:2, sort_order:3 },
    check_keys:[ :device_type, :keyword, :sort_order ],
    relation_strings:[ :Year, :Individual ], method_name: :albums

  describe "が存在するとき" do
    before :each do
      @asn = Album::AlbumStr2Num
      @album = Album.create(keyword:'akey1', j_title:'アルバム1', e_title:'Album1', sort_order:101, minutes:54, seconds:32,
                         device_type:@asn[:Album], jcomment:'コメント1', ecomment:'comment1')
      @single = Single.create(keyword:'skey', j_title:'シングル1', e_title:'Single1', sort_order:102, minutes:12, seconds:34,
                         device_type:Single::SingleStr2Num[:Single], jcomment:'コメント2', ecomment:'comment2')
      @album2 = Album.create(keyword:'akey2', j_title:'Album2', sort_order:103, minutes:43, seconds:21,
                         device_type:@asn[:LyricAlbum] | @asn[:PartAlbum], jcomment:'コメント3', ecomment:'comment3')
    end

    it "e_title が存在するとき,etitle に e_title を返すこと" do
      @album.jtitle.should == 'アルバム1'
      @album.etitle.should == 'Album1'
    end

    it "e_title が存在しないとき,etitle に j_title を返すこと" do
      @album2.jtitle.should == 'Album2'
      @album2.etitle.should == 'Album2'
    end

    it "comment でコメントが取得できること" do
      @album.comment(true).should == 'コメント1'
      @album.comment(false).should == 'comment1'
      @album2.comment(true).should == 'コメント3'
      @album2.comment(false).should == 'comment3'
    end

    it "whole_time で演奏時間が取得できること" do
      @album.whole_time(true).should == '54分32秒'
      @album.whole_time(false).should == '54\'32"'
      @album2.whole_time(true).should == '43分21秒'
      @album2.whole_time(false).should == '43\'21"'
    end

    it "device_type_str にてアルバムの種別が表示できること" do
      I18n.locale = :ja; @album.device_type_str.should == "アルバム"
      I18n.locale = :en; @album.device_type_str.should == "Album"
      I18n.locale = :ja; @album2.device_type_str.should == 'その他のアルバム (作詞した曲が収録), その他のアルバム (演奏に参加した曲が収録)'
      I18n.locale = :en; @album2.device_type_str.should == 'Album that contains her songs(Lyric), Album that she took part in playing songs'
      I18n.locale = :ja
    end

    it "has_device_type? にてアルバムの種別が判別できること" do
      @album.has_device_type?(@asn[:Album]).should be_true
      @album.has_device_type?(@asn[:LyricAlbum]).should be_false
      @album.has_device_type?(@asn[:PartAlbum]).should be_false
      @album2.has_device_type?(@asn[:Album]).should be_false
      @album2.has_device_type?(@asn[:LyricAlbum]).should be_true
      @album2.has_device_type?(@asn[:PartAlbum]).should be_true
    end

    it "event_str が取得できること" do
      I18n.locale = :ja; @album.event_str.should == "アルバム発売"
      I18n.locale = :en; @album.event_str.should == "Album release"
      I18n.locale = :ja
    end

    it "device_type_hash にて device_type ごとの hash_array が取得できること" do
      Album.should_receive(:order_sort_order).and_return([@album, @album2])
      dth = Album.device_type_hash
      dth[:Album].should == [ @album ]
      dth[:LyricAlbum].should == [ @album2 ]
      dth[:PartAlbum].should == [ @album2 ]
    end

    it "next_device にて次のアルバムが取得できること" do
      @album.next_device.should == @album2
    end

    it "prev_device にて前のアルバムが取得できること" do
      @album2.prev_device.should == @album
    end
  end
end

これらのテストを通すために、app/model/device.rb にメソッドを記述していきます。プログラムにはコメントを書いていないのですが,説明用に追記しておきます。

# vim:set fileencoding=utf-8 filetype=ruby:
class Device < ActiveRecord::Base
  include MiscMethod
  # Title module を Mix-in しているので、jtitle, etitle, jcomment, ecomment は実装済み。
  include Title
  
  # Rails3.2 から外部からアクセスしてよい属性を制御できるようになりました。
  attr_accessible :type, :device_type, :date, :e_title, :j_title, :keyword, :minutes, :number, :seconds, :sort_order, :jcomment, :ecomment
  # Rails3.2 からは新しい validates という表記もあるのですが、こっちの方がまとめて設定できるので未だに使っています。
  validates_presence_of :device_type, :keyword, :sort_order, message:"が空の状態で保存することはできません"
  # 所属側の関連。通常は関連名だけですが、singer はクラス名が異なるので記述が複雑になります。
  belongs_to :year
  belongs_to :singer, class_name: :Individual, foreign_key: :singer_id
  # 対他関連。
  has_many :media
  has_many :song_lists
  has_many :concert_halls
  # Arel 用の scope。
  # Rails 3.0 から実装された Arel によって、SQL がメソッドチェーンで書けます。
  # SQL の発行は実際に配列の中身が求められたとき(each や map 実行時)に遅延実行されます。
  # このおかげでデータベースの検索が非常に楽に書けるようになりました。
  scope :keyword_is, -> k { where 'devices.keyword' => k }
  scope :year_is, -> y { where 'devices.year_id' => y.id }
  scope :order_device_type, -> { order 'devices.device_type' }
  scope :order_sort_order, -> { order 'devices.sort_order' }
  scope :order_sort_order_desc, -> { order 'devices.sort_order desc' }
  scope :device_type_is, -> dt { where 'devices.device_type' => dt }
  scope :type_is, -> t { where 'devices.type' => t }
  scope :before_devices, -> d { where 'devices.sort_order < ?', d.sort_order }
  scope :after_devices, -> d { where 'devices.sort_order > ?', d.sort_order }

  DeviceTypes = [ :Single, :Album, :Video, :Youtube ]

  # 日付を取得
  def whole_time(is_ja)
    if minutes && seconds
      is_ja ? "#{minutes}#{seconds}" : "#{minutes}'#{seconds}\""
    end
  end

  # デバイスタイプ文字列を取得
  def device_type_str_from_table(table)
    ans = []
    table.keys.sort.each do |k|
      ans << I18n.t(table[k]) if has_device_type?(k)
    end
    ans.join(', ')
  end

  # 該当するデバイスタイプが存在するか
  def has_device_type?(v)
    device_type & v != 0
  end

  # デバイスタイプごとの hash_array を取得
  def self.device_type_hash_from_table(table)
    ans = Hash.new
    self.order_sort_order.each do |d|
      table.each_pair do |k, v|
        array = (ans[v] ||= [])
        array << d if d.has_device_type?(k)
      end
    end
    ans
  end

  # 次のアルバムを取得
  def next_device
    self.class.after_devices(self).order_sort_order.first
  end

  # 前のアルバムを取得
  def prev_device
    self.class.before_devices(self).order_sort_order_desc.first
  end

end

Device でほとんどのメソッドが記述できるので、サブクラスの Album ではアルバムに依存する定数やメソッドのみを実装すればよくなります。

# vim:set fileencoding=utf-8 filetype=ruby:
class Album < Device
  include MiscMethod

  AlbumStr2Num = { Album:1, MiniAlbum:2, KaraokeCD:4, LyricAlbum:8, MusicAlbum:16, CoverAlbum:32, PartAlbum:64, CompilationAlbum:128 }
  AlbumNum2str = AlbumStr2Num.invert

  # get_parameter
  def self.gp(main_key = false)
    [self, main_key ? :id : :album_id ]
  end

  def device_type_str
    device_type_str_from_table(AlbumNum2str)
  end

  def event_str
    I18n.t :album_release
  end

  def self.device_type_hash
    device_type_hash_from_table(AlbumNum2str)
  end

end

メソッドが正しく実装されると、rspec はオールグリーンになります。

Album
  を作成するとき
    正しいアトリビュートに対して作成が成功すること
    device_type 属性が設定されていない場合にバリデーションに失敗すること
    keyword 属性が設定されていない場合にバリデーションに失敗すること
    sort_order 属性が設定されていない場合にバリデーションに失敗すること
    Year に所属するオブジェクトが一つ増えること
    Individual に所属するオブジェクトが一つ増えること
  を削除するとき
    Year に所属するオブジェクトが一つ減ること
    Individual に所属するオブジェクトが一つ減ること
  が存在するとき
    e_title が存在するとき,etitle に e_title を返すこと
    e_title が存在しないとき,etitle に j_title を返すこと
    comment でコメントが取得できること
    whole_time で演奏時間が取得できること
    device_type_str にてアルバムの種別が表示できること
    has_device_type? にてアルバムの種別が判別できること
    event_str が取得できること
    device_type_hash にて device_type ごとの hash_array が取得できること
    next_device にて次のアルバムが取得できること
    prev_device にて前のアルバムが取得できること

Finished in 0.32582 seconds
18 examples, 0 failures
Done.