ActiveRecordはやっぱりActive Recordなのか

RoRで採用されているActiveRecord(AR)を利用してドメインモデルを作成してみました。
シナリオはハンバーガショップモデルを利用し、基本的な部分を実装してみました。
感想としてはARはやはりActive Recordであり、リッチなドメインモデルを作成するには向かないのではないかと感じました。理由は

  • オブジェクトキャッシュ機能の不足
  • データアクセス最適化の柔軟性が不足

です。

前者のオブジェクトキャッシュ機能の不足は、あるドメインモデルのオブジェクトが複数で参照されている場合必ず同一のオブジェクトが参照できる仕組みがARには不足しているということで、ARは非常にリッチな機能をもつがやはりActive Recordであって、Data Mapperで得られるドメインモデルやデータアクセスの実装自由度はないということで認識しました。

具体的な点としては、お買い上げ明細(OrderItem)のオプションが指定された時点でお買い上げ(Order)の総額を更新するモデルを作成したのですが、OrderItemから親のOrderを参照するとOrderItemごとにOrderが読みだされインスタンス化されてしまいます。このため、複数のOrderオブジェクトが生成されてしまいOrderの総額更新が意図した通りに動作しません。ARは非常にリッチであるのでこのあたりも上手く処理してくれると期待したのですが残念です*1。とりあえず、お買い上げ明細(OrderItem)作成時に明示的に親のお買い上げ(Order)を渡すようにして対応しています。
また、複数のエンティティをセット指向的に効率的に取り出す方法が良く分からずデータアクセス処理を最適化するための方法が提供されていないのではと感じました。

追記

Object-Relational Mapping as a Persistence Mechanism for Object-Oriented Applications

Active Record does not include any caching to speak of. This, of course, hampers
performance, but it is not without any advantage. The lack of object caching is
in line with Active Record’s database-oriented philosophy.

ARはキャッシュ機能なしでデータベース指向だそうだ。なるほど、割り切っているということですね。
さすがRoRで生まれた技術ということか。


Object Caching in Rails 2

A common complaint is that ActiveRecord is very chatty with the database

コード

以下今回作成したコードです。RubyRoR脳になっていないので、もしこの認識が誤っているのであれば突っ込んでください。もちろん、ARを利用すると非常に簡潔にコードが書けるのメリットは納得しています。

今回のお買い上げシナリオ

  def test_simple_order
    puts "オーダー開始"
    o = shops(:osaka).add_order()
    
    puts "チーズバーガ追加"
    i1 = o.add_item(Product.find_by_title('チーズバーガー'), 3)
    puts "合計確認"
    assert_equal 300, o.total_price

    puts "ハンバーガーセット追加"
    i2 = o.add_item(products(:burgerSet), 2)
    puts "合計確認"    
    assert_equal 800, o.total_price

    puts "ハンバーガーセット コーヒー指定"
    i2.add_option(products(:coffeeS))
    puts "合計確認"
    assert_equal 800, o.total_price

    puts "ハンバーガーセット ポテトM指定"
    i2.add_option(products(:potatoM))
    puts "合計確認"
    assert_equal 900, o.total_price
    
  end

ARを利用したドメインモデル

class Product < ActiveRecord::Base
  has_many  :product_items
  has_many  :product_item_options

  acts_as_tree :order => "id"

  attr_reader :items

  def after_find
    @items = Hash.new
    product_items.each { |t| @items[t.name] = t  }
  end

  def group?(p)
    false
  end
  
end

class SaleProduct < Product

  def necessary_options
    options = []
    self.product_items.each { |t| options << t.necessary_option if t.necessary? }
    options
  end

  def additional_price(option)
    self.product_items.each { |t| return t.additional_price(option) if t.additional_price(option) }
  end
  
end

class AttachmentProduct < Product
end

class GroupProduct < Product
  def group?(p)
    children.each do |c|
      return true if c == p || c.group?(p)
    end
    return false
  end
end

class ProductItem < ActiveRecord::Base
  belongs_to :product
  has_many  :options,
            :class_name => "ProductItemOption"

  def necessary?()
    self.options.size == 1
  end
  
  def necessary_option()
    self.options[0].product if necessary?
  end
  
  def additional_price(option)
    self.options.each do |o| 
      return o.additional_price if o.product == option || o.product.group?(option)
    end
    return nil
  end
end

class ProductItemOption < ActiveRecord::Base
  belongs_to  :product_item
  belongs_to  :product,
              :class_name => "Product",
              :foreign_key => "option_product_id"
end

class Order < ActiveRecord::Base
  has_many    :items,
              :class_name => "OrderItem"
  belongs_to  :shop
  
  def add_item(product, amount)
    item = self.items.create(:product => product, :amount => amount, 
        :unit_price => product.price, :parent => self)
    product.necessary_options.each {|o| item.add_option(o) }
    update_total_price()
    item
  end

  def update_total_price
    total = 0
    self.items.each { |item| total += item.sub_total_price }
    self.total_price =total
  end
end

class OrderItem < ActiveRecord::Base
  belongs_to  :order
  belongs_to  :product
  has_many    :options,
              :class_name => "OrderItemOption"
              
  attr_accessor :parent
  
  def add_option(product)
    self.options.create(:product => product)
    update_unit_price()
  end
  
  def sub_total_price
    self.unit_price * self.amount
  end
  
  def update_unit_price
    self.unit_price = self.product.price + option_additional_price
    self.parent.update_total_price if self.parent
  end
  
  def option_additional_price
    total = 0
    self.options.each { |option| total += self.product.additional_price(option.product) }
    total
  end
end

class OrderItemOption < ActiveRecord::Base
  belongs_to  :order_item
  belongs_to  :product
  
end

ちなみに発行されたSQL文を含めたログ

オーダー開始
SHOW FIELDS FROM shops
SELECT * FROM shops WHERE (shops.`id` = 1) 
SHOW FIELDS FROM orders
INSERT INTO orders (`order_as`, `total_price`, `shop_id`) VALUES('2007-07-20 23:26:46', 0, 1)
チーズバーガ追加
SHOW FIELDS FROM products
SELECT * FROM products WHERE (products.`title` = 'チーズバーガー')  LIMIT 1
SHOW FIELDS FROM products
SELECT * FROM product_items WHERE (product_items.product_id = 1) 
SHOW FIELDS FROM order_items
INSERT INTO order_items (`order_id`, `product_id`, `amount`, `unit_price`) VALUES(105, 1, 3, 100)
SELECT * FROM order_items WHERE (order_items.order_id = 105) 
合計確認
ハンバーガーセット追加
SELECT * FROM products WHERE (products.`id` = 2) 
SELECT * FROM product_items WHERE (product_items.product_id = 2) 
SHOW FIELDS FROM product_items
INSERT INTO order_items (`order_id`, `product_id`, `amount`, `unit_price`) VALUES(105, 2, 2, 250)
SHOW FIELDS FROM product_item_options
SELECT count(*) AS count_all FROM product_item_options WHERE (product_item_options.product_item_id = 1) 
SELECT count(*) AS count_all FROM product_item_options WHERE (product_item_options.product_item_id = 1) 
SELECT * FROM product_item_options WHERE (product_item_options.product_item_id = 1) 
SELECT * FROM products WHERE (products.`id` = 3) 
SELECT * FROM product_items WHERE (product_items.product_id = 3) 
SELECT count(*) AS count_all FROM product_item_options WHERE (product_item_options.product_item_id = 2) 
SELECT count(*) AS count_all FROM product_item_options WHERE (product_item_options.product_item_id = 3) 
SHOW FIELDS FROM order_item_options
INSERT INTO order_item_options (`order_item_id`, `product_id`) VALUES(155, 3)
SELECT * FROM order_item_options WHERE (order_item_options.order_item_id = 155) 
SELECT * FROM products WHERE (products.`id` = 3) 
SELECT * FROM product_items WHERE (product_items.product_id = 3) 
合計確認
ハンバーガーセット コーヒー指定
SELECT * FROM products WHERE (products.`id` = 8) 
SELECT * FROM product_items WHERE (product_items.product_id = 8) 
INSERT INTO order_item_options (`order_item_id`, `product_id`) VALUES(155, 8)
SELECT * FROM product_item_options WHERE (product_item_options.product_item_id = 2) 
SELECT * FROM products WHERE (products.`id` = 4) 
SELECT * FROM product_items WHERE (product_items.product_id = 4) 
SELECT * FROM products WHERE (products.`id` = 5) 
SELECT * FROM product_items WHERE (product_items.product_id = 5) 
SELECT * FROM products WHERE (products.`id` = 6) 
SELECT * FROM product_items WHERE (product_items.product_id = 6) 
SELECT * FROM product_item_options WHERE (product_item_options.product_item_id = 3) 
SELECT * FROM products WHERE (products.`id` = 14) 
SHOW FIELDS FROM products
SELECT * FROM product_items WHERE (product_items.product_id = 14) 
SELECT * FROM products WHERE (products.parent_id = 14)  ORDER BY id
SELECT * FROM product_items WHERE (product_items.product_id = 7) 
SELECT * FROM product_items WHERE (product_items.product_id = 8) 
合計確認
ハンバーガーセット ポテトM指定
SELECT * FROM products WHERE (products.`id` = 5) 
SELECT * FROM product_items WHERE (product_items.product_id = 5) 
INSERT INTO order_item_options (`order_item_id`, `product_id`) VALUES(155, 5)
合計確認

*1:何か設定または実装することで上手く対応できる方法があれば誰か教えてください