Ruby on Railsで複合キーを扱う(5)

2012/03/29

前回は、文字列と「期間」を複合キーとして用いているデータベーステーブルをRailsで扱う話の続きで、モデル間を1対多で関連づける方法について書きました。

今回は多対多です。

Category, CategoryProductLink モデルを作成

% rails g model category
% rails g model category_product_link

db/migrate/..._create_categories.rbを修正。

class CreateCategories < ActiveRecord::Migration
  def change
    create_table :categories, id: false do |t|
      t.string :code, null: false
      t.string :name, null: false
      t.date :started_on, null: false
      t.date :ended_on, null: true

      t.timestamps
    end
    
    add_index :categories, [ :code, :started_on ], unique: true
  end
end

db/migrate/..._create_category_product_links.rbを修正。

class CreateCategoryProductLinks < ActiveRecord::Migration
  def change
    create_table :category_product_links, id: false do |t|
      t.string :category_code
      t.string :product_code
      t.date :started_on, null: false
      t.date :ended_on, null: true

      t.timestamps
    end
    
    add_index :category_product_links,
      [ :category_code, :product_code, :started_on ], unique: true,
      name: "index_category_product_links_on_codes"
  end
end
% rake db:migrate

app/models/category.rbを修正。

class Category < ActiveRecord::Base
  include DurationLimited
end

app/models/category_product_link.rbを修正。

class CategoryProductLink < ActiveRecord::Base
  include DurationLimited
end

FactoryGirl

spec/factories/categories.rbを修正。

FactoryGirl.define do
  factory :category do
    sequence(:code) { |n| "category_%02d" % n }
    name { code.camelize }
    started_on { 2.years.ago }
    ended_on { nil }
  end
end

spec/factories/category_product_links.rbを修正。

FactoryGirl.define do
  factory :category_product_link do
    started_on { 2.years.ago }
    ended_on { nil }
    category_code do |link|
      FactoryGirl.create(:category,
        started_on: link.started_on, ended_on: link.ended_on).code
    end
    product_code do |link|
      FactoryGirl.create(:product,
        started_on: link.started_on, ended_on: link.ended_on).code
    end
  end
end

これで準備完了です。

RSpecによる試験コード

spec/models/category_spec.rbを修正。

# coding: utf-8

require 'spec_helper'

describe Category do
  subject { FactoryGirl.create(:category, code: "c0",
      started_on: Date.new(1999, 1, 1), ended_on: nil) }
  let(:product0a) { FactoryGirl.create(:product,
      code: "p0", name: "Product0a",
      started_on: Date.new(2000, 1, 1), ended_on: Date.new(2002, 1, 1)) }
  let(:product0b) { FactoryGirl.create(:product,
      code: "p0", name: "Product0b",
      started_on: Date.new(2002, 1, 1), ended_on: nil) }
  let(:product1) { FactoryGirl.create(:product,
      code: "p1", name: "Product1",
      started_on: Date.new(1999, 1, 1), ended_on: nil) }
  
  before do
    product0a
    product0b
    product1
  end
  
  it "2001年1月1日当時の製品(product)リストと関連づけできる" do
    FactoryGirl.create(:category_product_link,
      category_code: subject.code, product_code: "p0",
      started_on: Date.new(2000, 1, 1), ended_on: nil)
    FactoryGirl.create(:category_product_link,
      category_code: subject.code, product_code: "p1",
      started_on: Date.new(1999, 1, 1), ended_on: nil)

    DurationLimited.current_date = Date.new(2001, 1, 1)
    
    cat = Category.find(subject.code)
    cat.should have(2).products
    cat.products.map(&:name).sort.should == [ "Product0a", "Product1" ]
  end
end

spec/models/category_product_link_spec.rb は使わないので削除してください。

実装

まずはテストが正しく失敗することを確認します。

% rake spec

(省略)

Failures:

  1) Category 2001年1月1日当時の製品(product)リストと関連づけできる
     Failure/Error: cat.should have(2).products
     NoMethodError:
       undefined method `products' for #<Category:0x00000003c9db98>
     # ./spec/models/category_spec.rb:35:in `block (2 levels) in <top (required)>'

Finished in 0.06834 seconds
4 examples, 1 failure

続いて、app/models/category.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

これでテストは成功します。

% rake spec

(省略)

Finished in 0.06973 seconds
4 examples, 0 failures

単一の主キーidを用いているテーブルの場合は、

  has_many :category_product_links
  has_many :products, through: :category_product_links

と簡単に書けましたが、文字列と期間を複合キーとして用いている我々の例ではちょっと複雑なコードとなりました。

しかし、Ruby on Railsでも少し頑張れば複合キーを採用しているデータベースも扱えることが示せたかと思います。

次回は、我々の例でもhas_many :products, through: :category_product_linksみたいな書き方で簡単に関連づけをする方法がないかどうか探ってみます。