2012年9月30日日曜日

Mongoidで、空のフィールドを省略する

最近、RailsでMongoDBを使ったウェブアプリを作っています。MongoDBってお手軽で便利ですね。複数の階層を持った普通のJSON形式のオブジェクトをひとまとまりのデータとして管理できるので、正規化された複数のテーブルを外部キーで参照し合うようなRDBMS特有のややこしさがありません。それでいて、MongoDBはSQLのような高度なクエリも実行できます。

NoSQLはスケールアウトと結びつけて語られることが多いですが、 MongoDBは仮にスケールアウトできなかったとしても十分に価値あるDBMSだと感じています。

さて、RubyからMongoDBを使いたい場合、もちろんドライバだけで利用する方法もあります。しかし、RDBMSにO/Rマッパーがあるように、MongoDBにはODM(Object-Document Mapper)というものが存在します。RubyにおけるODMではMongoidMongoMapperの二つが有名です。が、どうやら最近はMongoidの方が人気があるようです。

僕もMongoidで開発をしています。

Mongoidで空のフィールドを省略する

ここからが本題。今回、Rubyは1.9.3を、Mongoidは2.2.5を使っています(Mongoidはちょっと古いです)。

今、ブログの記事を管理するソフトウェアを作っているとしましょう。Articleクラスでは、ブログの記事を個別に扱っています。

class Article
  include Mongoid::Document
  include Mongoid::Timestamps

  field :title, type: String
  field :text, type: String
  
  attr_accessible :title, :text

  validates_presence_of :title
end

Mongoidでは、fieldメソッドを用いてオブジェクトとドキュメントのフィールド間のマッピングを設定します。 このように書くと、Articleクラスにtitletextというアクセサが作られ、それぞれがMongoDBのArticleドキュメントのtitle, textフィールドに対応するようになります。見た目がずいぶんActiveRecordに似ていますが、それもそのはず、Mongoidは裏側ではRailsのActiveModelを多用しており、コールバックやバリデーションなどの機能はそれによって実現されているのです。

さて、例に戻りましょう。ここで各ブログ記事に写真を添えたいということになりました。一つの記事に複数の写真を載せられるようにしたいので、photosというフィールドを作り、記事に載せる写真のIDの配列を格納できるようにします(ここでは、写真の記事内での配置については考えないことにします)。

class Article
  include Mongoid::Document
  include Mongoid::Timestamps

  field :title, type: String
  field :text, type: String
  field :photos, type: Array
  
  attr_accessible :title, :text, :photos

  validates_presence_of :title
end

ここでちょっとした問題が発生します。すでにデータベースに格納されているドキュメントには、次のようにphotosフィールドはありません。

{ "_id" : ObjectId("4f24cd8f1d41c80cf4000002"), "title" : "記事1", "text" : "記事本文1", "updated_at" : ISODate("2012-01-29T04:39:43Z"), "created_at" : ISODate("2012-01-29T04:39:43Z") }

一方、これから作成するドキュメントには、写真がない記事にもphotos: []というフィールドが加わることになります。つまり、「写真がない」という同じ状態は、2通りの表現の仕方でデータベース側に格納されることになります。

これには、大きな実害はありません。Mongoidでは次のようにして、クラスのフィールドに対応するフィールドがドキュメントに存在しない場合のデフォルト値を設定することができます。

field :photos, type: Array, default: []

こうすることで、ドキュメントにphotosが存在しない場合であってもphotosが空の配列の場合であっても、article.photoは空の配列を返してくれるようになります(ちなみに、defaultが指定されていない場合、対応するフィールドがドキュメントに存在しないとarticle.photonilを返します)。これで、アプリケーション側は一件落着です。

しかし、依然としてデータベース内には2つの状態が残ります。この問題はどう考えればよいでしょうか?

a. 無視する

実害がないのであれば、わざわざ何かを変える必要はありません。ということで、無視します。

これは十分理にかなった考え方だと思いますが、プログラムの規模が大きく複雑になり、多くの人が開発に携わるようになると、誤解のもとになるかもしれません。

b. 全てのドキュメントにphotosフィールドを追加する

過去に作成されたものも含めて全てのドキュメントにphotosフィールドを追加します。これは、最も美しく、意思疎通の面でも誤解の余地がないベストな考え方だと思います。

ただ、すでに膨大な件数の記事を管理している場合、余計なフィールドが増えるために、データサイズが少し増えるかもしれません。

c. 空のフィールドは省略する

これが今回のメインです。

photosフィールドが空配列である状態を認めません。つまり、photosフィールドが存在する場合は必ず配列には一つ以上の要素が含まれているようにし、配列が空になる場合はphotosフィールドそのものを削除するようにします。

これは、各ドキュメントごとにフィールドを自由に追加、削除できるMongoDBならではの解決法といえるでしょう。

ただ、調べた限りではMongoidでこれを直接実現する方法はないようです。

そこで、Mongoidのソースコードを読んで次のような実装法を思いつきました。

class Article
  include Mongoid::Document
  include Mongoid::Timestamps

  field :title, type: String
  field :text, type: String
  field :photos, type: Array, default: []
  
  attr_accessible :title, :text, :photos

  validates_presence_of :title

  around_save :omit_empty_field

  def omit_empty_field
    if photos.empty?
      atomic_unsets.push(:photos) # photosを$unset
      reset_photos! # photosが再度$setされるのを防ぐ
      yield
      # 次のアクセスのために、値を元に戻す。
      # ActiveModel::Dirtyの値がおかしくならないよう、ここで行う。
      self.photos = []
    else
      yield
    end
  end

  protected :omit_empty_field
end

save時のコールバックでphotosが空かどうか調べ、空の場合には$unset(削除)を行うタスクを追加します。基本はそれだけなのですが、それだけでは不都合が生じるので、いくつか補助的な処理を行います。

フィールドが一つだけの場合は、これでよいでしょう。汎用性を持たせたい場合は、もっと別の書き方が必要になると思います。