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オプションの有無によってメソッド定義の方法を切り替えています。中身は少々複雑に見えますが、たいしたことはしていません。元のコードの中で変化する部分をnameoptions[:through]の値を規則的に変化させることでコードを生成しています。

DurationiLimitedのようなモジュールを準備をすれば、単一の主キーidではなく文字列と「期間」を複合キーとして用いているようなデータベーステーブルでもRails本来の簡潔さを保ちつつWeb開発を進めていくことができます。

ただし、今回ご紹介したbelongs_tohas_manyの実装は、テーブルを参照するためのカラムの名前が「テーブル名の単数形+"code"」と規則的に決められていることが前提となっていますので、現実の開発案件ではいろいろと細かい変更が必要になります。あくまで開発の方向性を示したに過ぎない点に留意してください。

おわりに

さて、6回に渡ってRailsにおける複合キーの扱い方について書いてきました。来週から個人的に少し忙しくなるので、いったんここで筆を置きたいと思います。

この連載の目的は「Railsでは複合キーを使えない」という誤解を解くことでした。

意味を持たない連番の主キーidが存在しないため少し処理が複雑になりますが、Railsでも複合キーは扱えます。ただし、連載の中で説明もせずに用いたActiveSupport::Concernmattr_accessordefault_scopeincludedmodule_evalなどのモジュールやメソッドは、Rails初級者にとっては見たことも聞いたこともない可能性が高いでしょう。とすれば、「Railsで複合キーを使うためにはやや高度なテクニックを要する」ぐらいのことは言ってもいいかもしれませんが、難易度はそんなに高くありません。

第1回で少し触れましたが、この連載は「データベーススキーマの変更はできない」という前提条件の下で書かれています。既存のデータベースがすでに稼働中で、システムの全部または一部をRailsで書き直すという仕事を想定しています。新たに主キーidを追加することが許可されれば異なった解決法があるでしょう。これについてはまた別の機会に書くかもしれません。