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
を追加することが許可されれば異なった解決法があるでしょう。これについてはまた別の機会に書くかもしれません。