Rails3で論理削除するプラグイン

Rails3のActiveRecord実装の勉強がてらにRails3専用のレコード論理削除プラグイン 'Millstone' を作成しました。

gem 'millstone'
class User < ActiveRecord
  millstone    # または acts_as_paranoid
end

User.with_deleted                            # 削除済みも含めて全部取得
User.only_deleted                            # 削除済みのみ取得
User.administrators.with_deleted     # NamedScopeからも利用できる
User.first.comments.with_deleted    # 関連からももちろん利用できる

User.first.destroy                             # 論理削除する
User.first.destroy!                            # 物理削除する

つくろうと思った経緯

色々と理由はあるけど、一番の理由は、rails3_acts_as_paranoidに僕が望んでいる以下の機能がなかったから。

  • Rails2世代にあった削除済みを含めた関連を取得するオプション
belongs_to :with_deleted => true
  • 削除済みも含めて関連のレコードを取得 ( rails3_acts_as_paranoid では関連でwith_deletedを使うとSQLがおかしなことになる。
User.first.associations.with_deleted

rails3_acts_as_paranoid は、Rails2世代のacts_as_paranoidのようにパワフルにはできてない感じだった。READMEにはシンプルにしたって書いてあったしね。

rails3_acts_as_paranoidをfork、拡張しようと頑張ってみたんだけど、「削除済みも含めて関連のレコードを取得」の実装が、default_scopeを生かしつつうまく実装できなかった。。
そこでRails3のActiveRecordの勉強がてらに自作してみることにした。

実装について

Rails3のActiveRecordを見てみると、データベースを操作するようなメソッドは ActiveRecord::Relation に委譲するように実装されていたので、ActiveRecord::Relation を拡張することで対応してみた。
嬉しいことにActiveRecord::Relation#extendingという指定されたモジュールをextendしてくれるメソッドが用意されていたので、論理削除モデルだけに拡張モジュールを組み込むようにすることができた。

拡張モジュールで対応したことは以下の通り。

  • 物理削除メソッドを用意した
  • 既存の削除メソッドを論理削除に書き換えた
  • 検索フラグを用意して、最終的にクエリを発行する際にフラグの状態をみて、検索条件に論理削除の条件を追加するように処理を書き換えた


実装はしてみたものの、拡張しすぎて3.0.0系でも3.0.6未満は動かない始末・・・
バランスって大切だなぁとあらためて思った。


ライブラリを作ることは多々あるけど、Gemとしての公開は初めてです。
よかったら触ってみてください。

TARGET_BUILD_DIR によるビルドエラーを強引に直してみた

TARGET_BUILD_DIR によるビルドエラー

Xcode4でTitanium 1.7.2の開発環境を作っていたらこんなビルドエラーがでてきた・・・

[ERROR] Your TARGET_BUILD_DIR is incorrectly set. Most likely you have configured in Xcode a customized build location. Titanium does not currently support this configuration.
[ERROR] Expected dir /Users/relax4u/Sources/KitchenSink/build/iphone/build/Debug-iphonesimulator, was: /users/relax4u/Sources/KitchenSink/build/iphone/build/Debug-iphonesimulator
[ERROR] Error: Traceback (most recent call last):
File "/Library/Application Support/Titanium/mobilesdk/osx/1.7.2/iphone/builder.py", line 1148, in main
execute_xcode("iphonesimulator%s" % link_version,["GCC_PREPROCESSOR_DEFINITIONS=__LOG__ID__=%s DEPLOYTYPE=development TI_DEVELOPMENT=1 DEBUG=1 TI_VERSION=%s %s" % (log_id,sdk_version,debugstr)],False)
File "/Library/Application Support/Titanium/mobilesdk/osx/1.7.2/iphone/builder.py", line 1088, in execute_xcode
sys.exit(1)
SystemExit: 1

TARGET_BUILD_DIR 系のビルドエラーで困っている方は結構いるようで、色々して調べて試してみたけどうまくいかない。
そもそも私のXcode力が足りないせいで、対応がうまくいっているのかもわからない状態だった。

builder.py の修正

行き詰っていても仕方がないので builder.py を修正して対応した。
/Library/Application Support/Titanium/mobilesdk/osx/1.7.2/iphone/builder.py を開いて 1083行目付近のコードを修正した。

  • 修正前
if target_build_dir!=build_dir :
  • 修正後
if target_build_dir.lower() != build_dir.lower() :


ビルドエラーの内容をみると、パスの不一致(Usersとusersの違い)で失敗しているようだったので、パスのチェックをしているロジックでパスを小文字に変換してチェックするように修正した。

[ERROR] Expected dir /Users/relax4u/Sources/KitchenSink/build/iphone/build/Debug-iphonesimulator, was: /users/relax4u/Sources/KitchenSink/build/iphone/build/Debug-iphonesimulator

こんな対応方法じゃなくてもっとまともな対応方法があるんだろうなぁ・・・

Rails3でmixiのライフサイクルイベントを実装してみた

oauth 0.4.4 ライブラリを利用してライフサイクルイベントの実装しようとしたんだけど、OAuthライブラリが生成しているBaseString が mixi で生成しているBaseStringと異なっているようで、Signatureの検証に失敗した。

なので、OAuth::RequestProxy::RackRequestを継承してmixi専用のリクエストの受け皿を用意した。

require 'oauth/request_proxy/rack_request'
require 'oauth/signature/rsa/sha1'

class MixiLifecycleEventRequest < OAuth::RequestProxy::RackRequest
  protected

  def header_params
    %w( X-HTTP_AUTHORIZATION Authorization HTTP_AUTHORIZATION ).each do |header|
      next unless request.env.include?(header)

      header = request.env[header]
      next unless header[0,6] == 'OAuth '

      oauth_params = OAuth::Helper.parse_header(header)
      oauth_params.reject! {|k, v| k =~ /^realm$/}
      
      return oauth_params
    end

    return {}
  end

  def request_params
    return {}
  end
end

OAuth::RequestProxy::RackRequest との違いは2点。

  • Authorizationヘッダーに含まれる oauth から始まるパラメータのみでBaseStringを生成していたが、mixiでは oauth から始まる以外のパラメータも必要と技術資料に書かれていたので、そちらを修正した。
  • POSTのパラメータもBaseStringに含めるようになっていたが、mixiでは必要がないようだ。よって常に空が返るように修正した。


後はコントローラ側で上のクラスを利用してSignatureの検証を行えばOK。

class LifecycleEventsController < ApplicationController
  MIXI_CERTIFICATE = "mixi certificate"   # mixi developer center にある公開鍵
  before_filter :verify

  # POST event.addapp
  def create
  end

  private

  def verify
    req = MixiLifecycleEventRequest.new(request)
    
    if req.oauth_signature_method == 'RSA-SHA1' and req.oauth_consumer_key == 'mixi.jp'
      consumer = OAuth::Consumer.new(nil, MIXI_CERTIFICATE, {:signature_method => 'RSA-SHA1'})
      signature = OAuth::Signature.build(req) { [nil, consumer.secret] }

      return if signature.verify
    end

    render :nothing => true, :status => :unauthorized
  end
end


この実装をする際にテストコードがほしくなったので、実際に失敗したヘッダー情報を基にSpecも書いてみた。
ホストやAuthorizationの情報は、mixiから実際にライフサイクルイベントが呼ばれたときの情報を設定しないと動かないので注意。

describe LifecycleEventsController do
  describe "#create" do
    before do
      request.host = "example.com"
      request.env["HTTP_AUTHORIZATION"] = "OAuth realm=\"\", oauth_consumer_key=\"mixi.jp\", oauth_nonce=\"84b101ab038ad1162657\", oauth_signature=\"xxxxxx\", oauth_signature_method=\"RSA-SHA1\", oauth_timestamp=\"1311750029\", oauth_version=\"1.0\", xoauth_signature_publickey=\"lc_20100419\""
    end 

    context "with valid parameters" do
      before { do_request }
      its(:response) { should be_success }
    end 

    def do_request(attributes = {}) 
      post :create, {:eventtype => "event.addapp", :opensocial_app_id => "xxxxxx", :id => "xxxxxxxxxx"}.merge(attributes)
    end 
  end
end

課題

この実装はまだ不十分。追加で実装しなければならないところがある。
mixiの技術資料では以下のようなパラメータで呼ばれることもあるらしい。Railsは同一パラメータで渡されると配列で格納されずに上書きされてしまう。これを回避するスマートな実装方法が思いつかない。

# http://example.com/?opensocial_app_id=xxxx&eventtype=event.addapp&id=1&id=2&id=3
def create
  params[:id] #=> 3
end

加工前のパラメータは以下から取れることは確認済み。スマートじゃないけど、これを利用してパラメータを加工しようかな・・・

request.env["rack.request.form_vars"]
request.env["rack.request.query_string"]