Ruby on Railsで複合キーを扱う(6) -- 最終回
2012/03/31
前回は、文字列と「期間」を複合キーとして用いているデータベーステーブルを前提として、モデル間を多対多で関連づけるRailsのコードを書いてみました。
今回はRubyプログラミングの「華」module_evalを利用して、ソースコードのリファクタリングを行います。
belongs_to
現在app/models/product.rbは、次のようになっています。
class Product < ActiveRecord::Base
include DurationLimited
def department
Department.where(code: department_code).first
end
end
これを次のように修正してください。
class Product < ActiveRecord::Base include DurationLimited belongs_to :department end
我々のデータベーステーブルには唯一の主キーidがありませんので、これは動きません。テストしてみましょう。
% rake spec
(省略)
Failures:
1) Product 2001年1月1日当時の部門(department)と関連づけできる
Failure/Error: p.department.name.should == department0.name
NoMethodError:
undefined method `name' for nil:NilClass
# ./spec/models/product_spec.rb:29:in `block (2 levels) in <top (required)>'
2) Product 2002年1月1日当時の部門(department)と関連づけできる
Failure/Error: p.department.name.should == department1.name
NoMethodError:
undefined method `name' for nil:NilClass
# ./spec/models/product_spec.rb:43:in `block (2 levels) in <top (required)>'
Finished in 0.06957 seconds
4 examples, 2 failures
ちょっと分かりにくいエラーメッセージが出ていますが、主キーを設定していないのが原因であることは明らかです。
クラスメソッドbelongs_toをオーバーライドして直しましょう。
app/models/duration_limited.rbを修正。
module DurationLimited
extend ActiveSupport::Concern
mattr_accessor :current_date
included do
default_scope do
where("started_on <= ? AND (ended_on > ? OR ended_on IS NULL)",
DurationLimited.current_date, DurationLimited.current_date)
end
end
module ClassMethods
def belongs_to(name)
module_eval <<-EOS, __FILE__, __LINE__ + 1
def #{name}
#{name.to_s.camelize}.where(code: #{name}_code).first
end
EOS
end
def find(code)
record = self.where(code: code).first
raise ActiveRecord::RecordNotFound unless record
record
end
end
end
belongs_toメソッドに:departmentというシンボルを与えると、
name.to_s.camelize
は、Departmentという文字列を返します。全体としては、belongs_toメソッドを呼ぶとdepartmentというインスタンスメソッドが定義される、という仕組みになっています。
これでテストは通ります。
% rake spec (省略) Finished in 0.06925 seconds 4 examples, 0 failures
has_many
has_many も同じように実装できます。
現在app/models/department.rbは、次のようになっています。
class Department < ActiveRecord::Base
include DurationLimited
def products
Product.where(department_code: code)
end
end
これを次のように修正してください。
class Department < ActiveRecord::Base include DurationLimited has_many :products end
rake specの実行結果は省略します。app/models/duration_limited.rbを次のように修正すればテストが通ります。
module DurationLimited
extend ActiveSupport::Concern
mattr_accessor :current_date
included do
default_scope do
where("started_on <= ? AND (ended_on > ? OR ended_on IS NULL)",
DurationLimited.current_date, DurationLimited.current_date)
end
end
module ClassMethods
def belongs_to(name)
module_eval <<-EOS, __FILE__, __LINE__ + 1
def #{name}
#{name.to_s.camelize}.where(code: #{name}_code).first
end
EOS
end
def has_many(name)
module_eval <<-EOS, __FILE__, __LINE__ + 1
def #{name}
#{name.to_s.singularize.camelize}.where(#{self.name.underscore}_code: code)
end
EOS
end
def find(code)
record = self.where(code: code).first
raise ActiveRecord::RecordNotFound unless record
record
end
end
end
Departmentクラスのクラスメソッドhas_manyに引数として:productsというシンボルを渡すと、
name.to_s.singularize.camelize
がProductという文字列を返し、
self.name.underscore
がdepartmentという文字列を返すので、全体として修正前のproductsとまったく同じメソッドが定義されることになります。
has_many through
最後に、Category#productsもやっつけましょう。
現在app/models/department.rbは、次のようになっています。
class Category < ActiveRecord::Base
include DurationLimited
def products
@product_codes ||= CategoryProductLink.where(category_code: code).
select(:product_code).map(&:product_code)
Product.where(code: @product_codes)
end
end
これを次のように修正してください。
class Category < ActiveRecord::Base include DurationLimited has_many :products, through: :category_product_links end
Rails標準のhas_many throughとは実装方法が異なるため、4行目の直前にhas_many :category_product_linksを記述する必要はありません。
app/models/duration_limited.rbを次のように修正。
module DurationLimited
extend ActiveSupport::Concern
mattr_accessor :current_date
included do
default_scope do
where("started_on <= ? AND (ended_on > ? OR ended_on IS NULL)",
DurationLimited.current_date, DurationLimited.current_date)
end
end
module ClassMethods
def belongs_to(name)
module_eval <<-EOS, __FILE__, __LINE__ + 1
def #{name}
#{name.to_s.camelize}.where(code: #{name}_code).first
end
EOS
end
def has_many(name, options = {})
if options[:through]
module_eval <<-EOS, __FILE__, __LINE__ + 1
def #{name}
@#{name.to_s.singularize}_codes ||= #{options[:through].to_s.singularize.camelize}.where(category_code: code).
select(:#{name.to_s.singularize}_code).map(&:#{name.to_s.singularize}_code)
#{name.to_s.singularize.camelize}.where(code: @#{name.to_s.singularize}_codes)
end
EOS
else
module_eval <<-EOS, __FILE__, __LINE__ + 1
def #{name}
#{name.to_s.singularize.camelize}.where(#{self.name.underscore}_code: code)
end
EOS
end
end
def find(code)
record = self.where(code: code).first
raise ActiveRecord::RecordNotFound unless record
record
end
end
end
throughオプションの有無によってメソッド定義の方法を切り替えています。中身は少々複雑に見えますが、たいしたことはしていません。元のコードの中で変化する部分をnameやoptions[:through]の値を規則的に変化させることでコードを生成しています。
DurationiLimitedのようなモジュールを準備をすれば、単一の主キーidではなく文字列と「期間」を複合キーとして用いているようなデータベーステーブルでもRails本来の簡潔さを保ちつつWeb開発を進めていくことができます。
ただし、今回ご紹介したbelongs_toやhas_manyの実装は、テーブルを参照するためのカラムの名前が「テーブル名の単数形+"code"」と規則的に決められていることが前提となっていますので、現実の開発案件ではいろいろと細かい変更が必要になります。あくまで開発の方向性を示したに過ぎない点に留意してください。
おわりに
さて、6回に渡ってRailsにおける複合キーの扱い方について書いてきました。来週から個人的に少し忙しくなるので、いったんここで筆を置きたいと思います。
この連載の目的は「Railsでは複合キーを使えない」という誤解を解くことでした。
意味を持たない連番の主キーidが存在しないため少し処理が複雑になりますが、Railsでも複合キーは扱えます。ただし、連載の中で説明もせずに用いたActiveSupport::Concern、mattr_accessor、default_scope、included、module_evalなどのモジュールやメソッドは、Rails初級者にとっては見たことも聞いたこともない可能性が高いでしょう。とすれば、「Railsで複合キーを使うためにはやや高度なテクニックを要する」ぐらいのことは言ってもいいかもしれませんが、難易度はそんなに高くありません。
第1回で少し触れましたが、この連載は「データベーススキーマの変更はできない」という前提条件の下で書かれています。既存のデータベースがすでに稼働中で、システムの全部または一部をRailsで書き直すという仕事を想定しています。新たに主キーidを追加することが許可されれば異なった解決法があるでしょう。これについてはまた別の機会に書くかもしれません。
