gem の内部を書き換える2つの方法 + おまけ(後から明示的に dependent: :destroy を止める方法 Rails3 限定)

皆さん、こんにちは。MUGENUP の osada です。 いきなりですが、

gem の内部を書き換えたい!

と思ったことはありませんか?

globalize というgem が、内部で

has_many :translations, dependent: :destroy

を実行するのですが、このdpendent: :destroyを止めたい、という要望から発生した、 gemの内部の挙動をオーバライドする方法のご紹介です。

想定する読者は、ruby を使い始めたばかりで、オープンクラスメタプログラミングを使ったことがない人です。

概要

dpendent: :destroyを止める方法として、下記の方法を紹介します。

  • オープンクラスを使ったパッチを当てる
  • module_evalを使ってパッチを当てる
  • has_many を再定義する
  • (Rails 3.2) destroy 用のメソッドをオーバーライドする
  • (Rails 3.2) destroy 用の callback を停止する

gem のクローンは最終手段

gem の挙動の一部を書き換えたいからといって、 gem を app などにコピーして使うのは、悪手だと考えています。 理由は、 バージョンアップに追従できない からです。 例えば、重要なセキュリティ修正 があった場合、それを取り込むのは大変難しくなるでしょう。

それよりも、gem をクローンし、パッチを宛てたものを 新しい gem として読み込む方が適切です。 この場合、クローン元であるオリジナルの gem をマージしてくることで、 バージョンアップに追従することができます。

しかしこれも、マージなどのメンテナンスコストを考えると最終手段だと考えます。

(もし、大きな修正が必要である場合、gem を継承して拡張するほうが適切でしょう)

オープンクラスを使ったモンキーパッチを当てる

軽度な修正であれば、モンキーパッチを当てる方が簡単です。 rubyオープンクラスは、拡張として使うことができます。

オープンクラスについては、 シンボルでも文字列でもアクセス可能なHashを使おう!ActiveSupport {Hash編} - MUGENUP技術ブログ でも触れました。

同名でクラス定義を行うと、クラスの再定義(上書き)ではなく、クラスへの追加拡張になる仕組み。

です。 つまりそれは、gem の中で定義したものであっても、app 側で再定義できる、ということです。

今回書き換えたい globalizehas_many は、vendor/bundle/gems/globalize-3.1.0/lib/globalize/active_record/act_macro.rb の、def setup_translates!(options)に定義されています。

module Globalize
  module ActiveRecord
    module ActMacro
      ......

      def setup_translates!(options)
        ......

        has_many :translations, :class_name  => translation_class.name,
                                :foreign_key => options[:foreign_key],
                                :dependent   => :destroy,
                                :extend      => HasManyExtensions,
                                :autosave    => false
        ......
      end
    end

    ......
  end
end

まずこのファイルを、config/initializer/extensions/globzlize/active_record/act_macro.rb にコピーし、setup_translates!以外を削除、has_many から dependent: :destroy を 削除します。

module Globalize
  module ActiveRecord
    module ActMacro
      protected
      def setup_translates!(options)
        ......

        has_many :translations, :class_name  => translation_class.name,
                                :foreign_key => options[:foreign_key],
                                :extend      => HasManyExtensions,
                                :autosave    => false
        ......
      end
    end
  end
end

以上、終了です。簡単ですね!

別の場所でクラスを開きなおして、メソッドを再定義するだけで良い のですから、 わざわざ gem をコピーしたり、クローンしたりする必要はありません。

メソッドチェインを使用する。

上記のメソッドを再定義するタイプのパッチは、非常に簡単ですが、 alias_method_chain などのメソッドに紐づいた実装を切ってしまう、という欠点があります。 どこかで誰かが、さらにパッチを当てているかもしれません。 できれば、メソッドチェインタイプのパッチを宛てる方が良いでしょう

module Globalize
  module ActiveRecord
    module ActMacro
      def setup_translates_with_stop_dependent_destroy!(options)
        setup_translates_without_stop_dependent_destroy!(options)

        has_many :translations, :class_name  => translation_class.name,
                                :foreign_key => options[:foreign_key],
                                :extend      => HasManyExtensions,
                                :autosave    => false
      end

      alias_method_chain :setup_translates!, :stop_dependent_destroy
    end
  end
end

実際、このglobalizeglobalize-versioning という拡張gemを用意しており、 このタイプの拡張でなければ、同時に使うとエラーになってしまいます。

module_eval を使ってパッチを当てる

さて、alias_method_chain であれば、オープンクラスである必要はなさそうな気がします。 それがもう一つの方法、module_eval を使う方法です。

config/initializers/globalize_patch.rb に先ほどのパッチをコピーします。 先ほどとは異なり、名前空間が異なるので、HasManyExtensions は、 ::Globalize::ActiveRecord::HasManyExtensions のように宣言する必要があります。

Globalize::ActiveRecord::ActMacro.module_eval do
  def setup_translates_with_stop_dependent_destroy!(options)
    setup_translates_without_stop_dependent_destroy!(options)

    has_many :translations, :class_name  => translation_class.name,
      :foreign_key => options[:foreign_key],
      :extend      => ::Globalize::ActiveRecord::HasManyExtensions,
      :autosave    => false
  end

  alias_method_chain :setup_translates!, :stop_dependent_destroy
end

以上、終了です。簡単ですね!

といっても、これらはコードを読む側からすると、 どこで何が再定義されているかわからない知らないところで何かが勝手に変わっている 、という意味で魔術的手法です。

用法・用量を守ってお使いください

オープンクラスと module_eval(class_eval) の違い

さて、この2つはどう違うのでしょうか?

みんな大好き stackoverflow に、回答がありましたので、引用します。 ruby - class_eval and open classes - Stack Overflow

reopen class will not inform you (but class_eval will raise error) if the existing class does not exist or not loaded.

class_eval は拡張元のクラスがないとき エラーになる。オープンクラスは何も教えてくれない。

オープンクラスというのは、クラスを定義する際の特徴的な動き、というだけのことであり、 そもそも拡張を意図としている訳ではない、ということですね。

特徴的なオープンクラス

他の言語では、module_eval のような拡張の仕方がメインなのではないでしょうか。

例えば、python であれば、後からクラスにメソッドを追加するときは、定義したメソッドを追加する、というコードになります。

まず、オープンクラスではないので、class A を再定義すれば、メソッドは消えてしまいます。

class A(object):
    def hello(self):
        print "hello", self.__class__.__name__

A().hello()
# => hello A

class A(object):
    def bye(self):
        print "bye", self.__class__.__name__

A().hello()
# Traceback (most recent call last):
#   File "tmp.py", line 11, in <module>
#     A().hello()
# AttributeError: 'A' object has no attribute 'hello'

よって、メソッドを定義し、クラスに追加することで、拡張します。

class A(object):
    def hello(self):
        print "hello", self.__class__.__name__

def bye(self):
    print "bye", self.__class__.__name__

A.bye = bye

a = A()
a.bye()
a.hello()
# => bye A
# => hello A

メソッドへの割り込みは例えば以下のようになるかもしれません (他にも拡張の方法があると思います)。

class A(object):
    def hello(self):
        print "hello", self.__class__.__name__

def hello_with_bye(self):
    self.hello_without_bye()
    print "bye", self.__class__.__name__

A.hello_without_bye = A.hello
A.hello = hello_with_bye

A().hello()
# => hello A
# => bye A

coffeescript(javascript) の場合も同じです。

class A
  hello: ->
    console.log "hello"

new A().hello()
# hello

class A
  bye: ->
    console.log "bye"

new A().hello()
# TypeError: Object #<A> has no method 'hello'
#   at Object.<anonymous> (/Users/osada/tmp.coffee:11:9)
#   at Object.<anonymous> (/Users/osada/tmp.coffee:1:1)
#   at Module._compile (module.js:456:26)
class A
  hello: ->
    console.log "hello"

A.prototype.bye = -> console.log "bye"

a = new A()
a.hello()
a.bye()
# hello
# bye
class A
  hello: ->
    console.log "hello"

A.prototype.hello_without_bye = A.prototype.hello

A.prototype.hello = ->
  this.hello_without_bye()
  console.log "bye"

new A().hello()
# hello
# bye

……ちょっと例が悪かったでしょうか。 pythonjavascript は大変親和性が高いので、 同じようなコードになってしまいました。

いずれにせよ、

  1. クラス定義
  2. クラスに対して、メソッドを追加

という手順を踏むことになります。

オープンクラス のように

  1. クラス定義
  2. 同名のクラス定義を使用して、定義の続きを行う

という動作は、他の言語と比べると、かなり特徴的であると思います。

予想外の箇所でオーバライドしてしまうこともあるため、 理解して使うことがオススメです。

コラム [オープンクラスでテストが落ちた]

単体テストは通るのに、全体テストが通らない、という原因がオープンクラスだったことがあります。 モジュールのテストのために、適当なクラスを宣言して、include して使っていた所、

class Foo
 include SomeModule
end

別の箇所でも Foo を定義しており、全体テストのときのみ、再定義が発動して テストが落ちていました。 他の言語であれば、同名のクラスは再定義されるので、no method errorで落ちるところが、 オープンクラスでは、メソッド自体は定義されないので落ちないが、 他で定義されたコードにより想定外の干渉が起きるということです。 オープンクラスならではだと思います。

has_many を再定義する

さて、パッチは魔術だ、というお話を致しました。 実際、該当のソースを読んだだけでは、dependent: :destroy が無効化されていることには気づけないでしょう。

できるなら、明示的に オーバライドしていることを宣言したいところです。

この globalizetranslates メソッドを呼ぶときに、内部で has_many を定義しています。

translates :description, versioning: :paper_trail
# translates メソッドを呼ぶと、gem 内のメソッドで下記が実行される
has_many :translations, :class_name  => translation_class.name,
                                :foreign_key => options[:foreign_key],
                                :dependent   => :destroy,
                                :extend      => HasManyExtensions,
                                :autosave    => false

よって、translates の後に has_many を再定義してしまえば、上書きされます。

例えば、Project というクラスに使用した場合、 translation_class.nameProject::Translation になります。

class Project
  translates :description, versioning: :paper_trail

  has_many :translations, :class_name  => Project::Translation,
    :foreign_key => nil,
    :extend      => ::Globalize::ActiveRecord::HasManyExtensions,
    :autosave    => false

これを毎回書くのは大変なのですが、読み手には優しくなります。 もう少し楽に書きながら、明示する方法はないでしょうか?

destroy 用のメソッドをオーバーライドする (Rails3.2)

dependent: :destroy は、before_destroy で発動します。

このメソッドをオーバーライドしてしまえば、 事実上、destroy を止めることができます。

メソッドはhas_many で定義されており、名前は "has_many_dependent_for_#{name}"。 つまり今回で言うと、"has_many_dependent_for_translation" です。

def configure_dependency
  if options[:dependent]
    ............
    model.before_destroy dependency_method_name
  end
end

def dependency_method_name
  "has_many_dependent_for_#{name}"
end

よって、translates の後に、true を返すメソッドとして再定義すればOKです。

translates :description, versioning: :paper_trail
has_many_dependent_for_translations = -> { true }

このように再定義できることを知っておくと、コールバックに割り込みができて便利です。 しかし単にコールバックを止めることはできないのでしょうか? 実は、skip_callbackとして下記のコードを書いても、停止されなかったのです。

translates :description, versioning: :paper_trail
skip_callback :destroy, :before, :has_many_dependent_for_translations

ここには一つ、バッドノウハウを知っている必要があります。

destroy 用の callback を停止する(Rails3.2)

結論から書くと、dependen: :destroy のコールバック自体を止めるには下記のように書きます。

translates :description, versioning: :paper_trail
skip_callback :destroy, :before, "(has_many_dependent_for_translations)"

括弧をつけたメソッド名の文字列 です。

callback には、メソッド名のシンボル、文字列、または Proc を設定できます。 ここに登録されるときの名前が、文字列の場合には 括弧が付く ようです。

def _compile_filter(filter)
        method_name = "_callback_#{@kind}_#{next_id}"
        case filter
        when Array
          filter.map {|f| _compile_filter(f)}
        when Symbol
          filter
        when String
          "(#{filter})”

こちらは destroy コールバック自体を止めているので、一番適切な止め方だと考えています。 括弧が付くというのは、かなりのハマりどころでした。

しかし残念なことに、Rails4.0 でこの実装はなくなってしまいました。 汎用的にコールバックを停止することは難しいようです。

Rails 4 では、メソッド名に由来する削除ができない

Rails 4 では、コールバックの登録が lambda になっており、 メソッド名を使用した、オーバーライドや、コールバックの停止、が出来なくなっています。

def self.add_before_destroy_callbacks(model, reflection)
  .......
  model.before_destroy lambda { |o| o.association(name).handle_dependency }
end

before_destroy での登録が lambda なので、 skip_callback を使おうとしても対象が宣言できないためです。

よって、対応策として、下記の2つを考えました。

  • reset_callbacks(:destroy)
  • callbacks を直接操作する

reset_callbacks は全てのコールバックを消してしまうため、 dependent: :destroy 以外の destroy callback を使っている gem には、使用できません。 また、他の destroy よりも前に宣言しておく必要があります。

translates :description, versioning: :paper_trail
reset_callbacks(:destroy)

一方、callbacksを直接操作するのは、かなりの黒魔術です。

translates :description, versioning: :paper_trail
skip_callback :destroy, :before, _destroy_callbacks.instance_variable_get(:@chain).last.filter

こちらは、直近に登録された filter を取り出すことで、skip_callback を使っています。 こちらも、複数のcallbackが登録されていた場合、dependent: :destroylast とは 限らないため、慎重な使い方が要求されますし、使うべきではないでしょう。

よって、Rails 4 の場合、明示して打ち消すのは難しいようです

まとめ

gem の中を書き換えるには、下記の4つの方法が考えられます。

  • app に コピーして修正して使う
  • クローンして修正した gem を使う
  • オープンクラスでパッチを当てる
  • module_eval(class_eval) でパッチを当てる

dependent: :destroy を止めるには、Rails 3 系であれば、下記2つを使い、明示的に止めることができます。

  • destroy 用のメソッドをオーバーライドする
  • destroy 用の callback を停止する

しかし、Rails 4 では、パッチを当てることが有効でしょう。

ということで、gem の内部を書き換える方法の共有でした。

検索用

今回、幾つかググっても情報が出てこなかったので、検索用に英語用のワードも記載しておきます。 この記事が誰かの助けになることができれば幸いです。

Cancel (remove) the dependent destroy defined in gem

「Qiitaの中の人とQiita:Teamを使う人と共に理想のチームについて考えてみませんか?」というイベントに出てきました

こんにちはMUGENUPの伊藤です。

2014年5月15日(木)に開催された「Qiitaの中の人とQiita:Teamを使う人と共に理想のチームについて考えてみませんか?」というイベントでQiita:Teamを使う人としてお話させていただきました。

f:id:mgnup:20140518194749j:plain

MUGENUPでは昨年の夏頃からQiita:Teamを使っています。 以前にはIncrementsさんに事例として取り上げていただきました。

Qiita:Teamを使った議事録の共有・保存で仕事の効率化をめざす MUGENUP CTO 伊藤勝悟氏 - Qiita Blog

イベント当日はIncrements代表取締役の海野さんと二人で「チーム」「組織」「情報共有」「コニュニケーション」と言った切り口から各社でどのような取り組みをしているかについてお話させていただきました。

ここでは弊社のコミュニケーションについて起こった問題と解決のためのトライを書いてみます。

エンジニアと他部署でのコミュニケーションの必要性

開発部ではクラウドソーシングのためのクリエイター、クライアント、MUGENUPを結ぶ管理システムをメインで開発しています。 このシステムは社内の殆どの人が日々の仕事をする上で多くの時間を割く重要なものです。

自分たちが作っているものを実際に使う人(=社内の人)とコミュニケーションを取ることは重要な事です。

人が増えてオフィスが増えた

現在MUGENUPはオフィスが3部屋あります。 これまで人数の増加に伴い段々と拡張してきました。

開発部も途中から新しい部屋にお引っ越しになり、当初は広い部屋を専有して開発していたのですが、あっという間にその部屋も埋まってしまいました。

コミュニケーションが減る

部屋がわかれると他の部屋にいる人たちとのコミュニケーションが大きく減ります。 一日中顔も合わせないということもざらにあります。

自分たちが作っているものを実際に使っている人とのコミュニケーションが減ってくると、 今まで出来ていた開発時の意思疎通が難しくなってきます。

どういう対応をしているか

そこで、現在開発部と別の部署でコミュニケーションを取る機会を増やしています。 一緒にランチしたり、時間をとってもらいどのように仕事をしているかの話を聞いたりなどです。

ただ、これは一時的な解決の部分もあり、より人数が増えた場合どうするのかといった問題も有ります。 そのような点も踏まえて開発の仕組みを考える必要があると再認識しました。

(全社的な取り組みについて)

イベントでお話した際に皆さんの反応が大きかったのが「パワーランチ」という取り組みです。 これは新しく入った社員の方が「他の部署の人たちとなかなか接する機会がない」ということで提案した企画でした。

内容は週に一回各部署から何人かずつ選び、その人達でランチに行く(経費は会社持ち)というものでした。

3月くらいから初めて、先日全社員が一通り行き終わったところだったのですが、私も普段から開発部の人たちとしかランチには行かないのでなかなか新鮮な体験でした。

「うちの会社でも似たようなことやってます」という方もいらっしゃったので、結構メジャーな企画なんですかね?

ドメイン名を使ってEC2を運用していたら、ELBのスケールアウトで苦労した話

2014年5月14日 13時00分 修正 タイトルが誤解を招くものだったので、「なぜ URL に www を付けるのか。または、サブドメインなしでは CNAME が使えない件」から変更致しました。併せて、画像に Public IP と Private IP の明記を行いました。

皆さん、こんにちは。MUGENUP の osada です。 今回は、スケールアウト時にELB(Amazon Elastic Load Balancer) を使うときの注意点についての記事です。 といっても、インフラ・エンジニアには自明のことと思いますので、読者の対象は インフラ・エンジニアではないけど、インフラもやるというベンチャーならではのエンジニア向けです。

要旨

  • ELBにはEIPなどのAレコードを関連付けることが出来ず、ドメインとサーバーを結びつけるには提供されるCNAMEを使う必要があります
  • サブドメイン無しのドメイン名 (zone apex) には、CNAMEレコードを貼ることはできません。
  • ELB を使ったスケールアウトを考えているなら、zone apex ではなく、サブドメイン で運用すると簡単です
  • zone apx を使って運用する場合、スケールアウトには、route 53 が必要そうです。

弊社 のインフラは AWS です。

まず弊社のインフラをお話すると、AWS(Amazon Web Service)を使用しており、CTO の伊藤がメイン・オペレーションを担当しています (彼は TCP/IP や WebSocket の RFP などを嬉々として語る通信系のツワモノです)。 しかし、それでは CTO が 単一障害点(Single Point of Failure, SPOF) となってしまうため、 社員全員が AWS へのアクセス権を持ち、一通りオペレーションができる状態となっています。

スケールアウトの頓挫

さて、先日、ご好評いただいている弊社のゲーム攻略サイトみなゲー(β版)が アクセス増のため、スケールアウトが必要となりました。

サーバ1台だったものを、ロードバランサーを使って、2台構成にする という、とても単純な作業です。 ……と、その時はまだ思っていたのです。

元々は、サーバ1台に対して、IP を使って DNS を貼っていました。

f:id:mgnup:20140514121236p:plain

そこで、ELBに従来のIPを設定し、ELBから、新しい2台のサーバに振り分けることを計画しました。

f:id:mgnup:20140514121239p:plain

ところが、AWSロードバランサーである、ELB (Elastic Load Balancing) には、 IP を振ることができなかったのです。

ELB が IP を持たない理由

SPOF(単一障害点) の話を上述しましたが、これはロードバランサーにおいても同じです。 もしロードバランサー1台が壊れてしまったら、どのサーバにもアクセスできなくなってしまいます。 ELBはそれを避けるため、複数台のロードバランサーを1つとして扱っているようです。 そのため、ELB 自体には、DNS が振られるのであって、IP を振ることはできません。

f:id:mgnup:20140514121241p:plain

IP を振る とは? [DNS の設定]

DNS とは、ドメイン名(mina-game.com など)を扱うサービスです。

ドメイン名の扱い方には複数ありますが、 本稿では、IPアドレスと結びつけるAレコードと、別名を付けるCNAMEレコードを取り上げます

レコード名 役割
A (Adress) レコード IPアドレスと結びつける mina-game.com => 123.456.78.90
CNAME レコード ドメイン名に別名を付ける mina-game.com => www.mina-game.com

今まで、mina-game.com にアクセスすると、123.456.78.90 のサーバにアクセスできていたのは、 Aレコード が付いていたからです。

ところが、ELB には IP を付けられない ため、Aレコードを貼ることができません。

よって、この場合、 CNAME を貼る ことが正解となります。

……そう、その時は、それで解決すると思っていたのです。

CNAME には、サブドメイン(ホストドメイン)が必須

ところが、ドメインmina-game.com に対して、CNAME を振ることは出来ない のです。

f:id:mgnup:20140514121244p:plain

少々長いですが、AWS から引用します。

ドメイン名を登録する際は、ドメイン名自体だけでなく、一連のサブドメイン名全体も予約することになります。例えば、example.com をカスタムドメイン名として登録する場合、foo.bar.example.com や foo.myLB.example.com などのサブドメイン名を作成することができます。この一連のドメインサブドメイン名は、ゾーンと呼ばれます。予約した example.com などのドメイン名は、ゾーンの階層の最上部にあるため、Zone Apex と呼ばれます。 DNS ルールでは Zone Apex(example.com など)での CNAME レコードの作成が許可されていません。例えば、example.com というドメイン名を所有している場合、CNAME レコードはサブドメイン名である foo.example.com には使用できますが、Zone Apex である example.com には使用できません。 http://docs.aws.amazon.com/ja_jp/ElasticLoadBalancing/latest/DeveloperGuide/using-domain-names-with-elb.html

mina-game.comゾーンの階層の最上部にある、Zone Apex であり、それには CNAME を使用する事ができないのです。

もし、www.mina-game.com などの サブドメイン を指定してあったなら、 cname の差し替えで期待どおりの処理が終わっていたことでしょう。 しかし、今回は zone apex で運用していたため、ELB を使ったスケールアウトをすることはできませんでした。

Route 53 を使用する

zone apex で運用を始めてしまった以上、ELB でのスケールアウトは無理なのでしょうか?

AWSのヘルプの続きに、こう書いてあります。

Zone Apex とロードバランサーDNS 名を関連付ける場合は、次のオプションを使用します。 Option 2: DNS サービス Amazon Route 53 はホストゾーンのドメインに関する情報を格納するため、Amazon Route 53 を使用してドメインを作成します。

あ、Route 53 使えば良いんですね。

まるでこのために作られたのではないかと思えるくらい、 完璧なお膳立てで、AWS 製の DNS Amazon Route 53 を案内されてしまいました。

というわけで、zone apex を使って AWS ELB でスケールアウトするには、route 53 を使おう、という結論に落ち着きました.

f:id:mgnup:20140514121247p:plain

元々は使用していなかったサービスが一つ追加で必要になってしまいましたが、無事目的を達成出来ました。

まとめ

  • ELBにはEIPなどのAレコードを関連付けることが出来ず、ドメインとサーバーを結びつけるには提供されるCNAMEを使う必要があります
  • サブドメイン無しのドメイン名 (zone apex) には、CNAMEレコードを貼ることはできません。
  • ELB を使ったスケールアウトを考えているなら、zone apex ではなく、サブドメイン で運用すると簡単です
  • zone apx を使って運用する場合、スケールアウトには、route 53 が必要そうです。

というわけで、インフラ初心者が送る スケールアウトで困った経験の共有でございました。

参考

もっと詳しい解説

【Rails3.2】【バッドノウハウ】includes 使用時に where で ドットを使うと想定外になるかも知れない件、または、なぜRails4 から references が必要になったのか

皆さん。お久しぶりです。MUGENUP の osada です。 某死にすぎるゲームの1周目をクリアしたので、戻ってまいりました! 目標を達成するために大事なことは観察力である、ということを思い知らされるゲームでした。

さてそんな今回は、Rails 3.2includes に関するバッドノウハウの共有です。 弊社 okuda が、トライ・アンド・エラーの末に見つけた、バグの解説になります。 Rails 4 系 をお使いの方には不要ですので、何かの話のネタになれば幸いです。

includes は LEFT OUTER JOIN と eager loading の 2 種類がある

Railsでは、includes は 2種類の使い方ができます。 1. eager loading 2. LEFT OUTER JOIN

通常の使い方だと、(1) の eager loading となり、 where 句の中で、includes した テーブルを参照すると、 (2) の LEFT OUTER JOIN となる、という認識でした。

class User < ActiveRecord::Base
  attr_accessible :email, :name
  has_many :user_items
end

describe User do
  let!(:user) { create :user, name: "user0", email: "user0@example.com" }

  it "eager loading" do
    subject = User.where("`name` LIKE ?", "%user0%").explain
    expect(subject).to include "SELECT `users`.* FROM `users`  WHERE (`name` LIKE '%user0%')"
    # => User Load (0.9ms)  SELECT `users`.* FROM `users` WHERE (`name` LIKE '%user0%')
    # => UserItem Load (1.3ms)  SELECT `user_items`.* FROM `user_items` WHERE `user_items`.`user_id` IN (252)
  end

  it "outer join by string" do
    subject = User.includes(:user_items).where("user_items.number IS NULL").explain
    expect(subject).to include "LEFT OUTER JOIN"
    # => SQL (2.0ms)  SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`email` AS t0_r2, `users`.`created_at` AS t0_r3, `users`.`updated_at` AS t0_r4, `user_items`.`id` AS t1_r0, `user_items`.`user_id` AS t1_r1, `user_items`.`item_id` AS t1_r2, `user_items`.`number` AS t1_r3, `user_items`.`created_at` AS t1_r4, `user_items`.`updated_at` AS t1_r5 FROM `users` LEFT OUTER JOIN `user_items` ON `user_items`.`user_id` = `users`.`id` WHERE `user_items`.`number` IS NULL
  end
end

一体何が問題なのか?

上でLEFT OUTER JOIN になる条件を

where 句の中で、includes した テーブルを参照する と書いたのですが、それは誤りでした。

端的に言うと、 where 句の中で、ドットが含まれていれば、LEFT OUTER JOIN とみなす 、ということだったのです。

何が問題になるのでしょうか?

皆さん。where 句に email を使ったことはありませんか?

it "like email" do
  subject = User.includes(:user_items).where("`email` LIKE ?", "%user0@example.com%").explain
  expect(subject).to include "LEFT OUTER JOIN"
  expect(subject).to include "SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`email` AS t0_r2, `users`.`created_at` AS t0_r3, `users`.`updated_at` AS t0_r4, `user_items`.`id` AS t1_r0, `user_items`.`user_id` AS t1_r1, `user_items`.`item_id` AS t1_r2, `user_items`.`number` AS t1_r3, `user_items`.`created_at` AS t1_r4, `user_items`.`updated_at` AS t1_r5 FROM `users` LEFT OUTER JOIN `user_items` ON `user_items`.`user_id` = `users`.`id` WHERE (`email` LIKE '%user0@example.com%')"
end

user_itemsjoin するつもりがないのに、join されています。 email にはほとんど必ずドットが 含まれます。 email 以外では eager loading なのに、email で where をした途端に、いきなりLEFT OUTER JOIN になってしまうのです。 無論それは、email に限りません。nameでドットが含まれていれば同じことです。

it "like name with dot" do
  subject = User.includes(:user_items).where("`name` LIKE ?", "%user0.name%").explain
  expect(subject).to include "LEFT OUTER JOIN"
end

さらには、stringで構築しているから、という理由でもありません。 arel_table で構築しても、結果は同じです。

it "like name with dot on arel" do
   subject = User.includes(:user_items).where(User.arel_table[:name].matches("%user0.name%")).explain
   expect(subject).to include "LEFT OUTER JOIN"
end

なぜこんなことになってしまうのでしょうか?

それは、eager loadingLEFT OUTER JOIN を区別するものが、下記のメソッドだからです。

references_eager_loaded_tables? で、どちらなのかを決めますが、 その決定権を握るのがtables_in_stringです。

def references_eager_loaded_tables?
  joined_tables = arel.join_sources.map do |join|
    if join.is_a?(Arel::Nodes::StringJoin)
      tables_in_string(join.left)
    else
      [join.left.table_name, join.left.table_alias]
    end
  end

  joined_tables += [table.name, table.table_alias]

  # always convert table names to downcase as in Oracle quoted table names are in uppercase
  joined_tables = joined_tables.flatten.compact.map { |t| t.downcase }.uniq

  (tables_in_string(to_sql) - joined_tables).any?
end

def tables_in_string(string)
  return [] if string.blank?
  # always convert table names to downcase as in Oracle quoted table names are in uppercase
  # ignore raw_sql_ that is used by Oracle adapter as alias for limit/offset subqueries
  string.scan(/([a-zA-Z_][.\w]+).?\./).flatten.map{ |s| s.downcase }.uniq - ['raw_sql_']
end

string.scan の中で、sql の中に、ドットが含まれているかどうか、のみで判断していることが見て取れると思います。

末尾の行を pry した結果が下記です。

[1] pry(#<ActiveRecord::Relation>)> to_sql
=> "SELECT `users`.* FROM `users`  WHERE (`users`.`name` LIKE '%user0.name%')"
[2] pry(#<ActiveRecord::Relation>)> tables_in_string(to_sql)
=> ["users", "user0"]
[3] pry(#<ActiveRecord::Relation>)> joined_tables
=> ["users"]
[4] pry(#<ActiveRecord::Relation>)> (tables_in_string(to_sql) - joined_tables).any?
=> true

確かに、ドットで判断していることが分かります。

まとめ

以上が、Rails3.2で、includesに起きる、バッドノウハウです。 知っていればどうということはありませんが、意図せず想定外の結果を産むこともありますので、十分お気をつけ下さい。

むしろ、この機会に、最近リリースされたRails4.1.0に乗り換えるのも良いのではないでしょうか。

(おまけ) Rails 4.1 では、LEFT OUTER JOIN の使用に、references が必要になった。

さて、そんなわけで、Rails 4から、LEFT OUTER JOINしたい時は明示的に、references` を書くことになりました。

User.includes(:user_items).where("user_items.number IS NULL")
# => Mysql2::Error: Unknown column 'user_items.number' in 'where clause': SELECT `users`.* FROM `users`  WHERE (user_items.number IS NULL)

User.includes(:user_items).where("user_items.number IS NULL").references(:user_items)
# => SQL (0.4ms)  SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`email` AS t0_r2, `users`.`created_at` AS t0_r3, `users`.`updated_at` AS t0_r4, `user_items`.`id` AS t1_r0, `user_items`.`user_id` AS t1_r1, `user_items`.`item_id` AS t1_r2, `user_items`.`number` AS t1_r3, `user_items`.`created_at` AS t1_r4, `user_items`.`updated_at` AS t1_r5 FROM `users` LEFT OUTER JOIN `user_items` ON `user_items`.`user_id` = `users`.`id` WHERE (user_items.number IS NULL)

上述のメソッドは、下記のように書き換わりました

def references_eager_loaded_tables?
  joined_tables = arel.join_sources.map do |join|
    if join.is_a?(Arel::Nodes::StringJoin)
      tables_in_string(join.left)
    else
      [join.left.table_name, join.left.table_alias]
    end
  end

  joined_tables += [table.name, table.table_alias]

  # always convert table names to downcase as in Oracle quoted table names are in uppercase
  joined_tables = joined_tables.flatten.compact.map { |t| t.downcase }.uniq

  (references_values - joined_tables).any?
end

変更されているのは、末尾の (references_values - joined_tables).any? です。 3.2系のときは、(tables_in_string(to_sql) - joined_tables).any? でした。

これらの問題については、こちらで検討され、修正がされたようです。 includes eager loading is realized with LEFT OUTER JOIN strategy when querying with value contains 'dot' · Issue #7177 · rails/rails

Deprecatedになった、ということでしたが、今回試したところ、エラーになりました。 確認したところ、Rails 4.1 では、使用不可になったようです。 Remove implicit join references that were deprecated in 4.0.

(おまけ 2) Arel を使おう

事例を説明するために、where の中に文字列を書きましたが、sexy ではありません。 綺麗に書きましょう。

it "like name call outer join" do
    subject = User.includes(:user_items).where(user_items: { number: nil } ).explain
    expect(subject).to include "LEFT OUTER JOIN"
end

(おまけ 3)

使用したテストを置いておきます。

備考1

SQLの確認に、to_sql ではなくexplain を使っているのは、Rails 3系のto_sqlは、eager loading を考慮しないためです。 Rails 4系では修正されています。

ActiveRecord::Relation#to_sql does not match SQL executed when using includes · Issue #6132 · rails/rails

MUGENUP勉強会を開催しました!

こんにちは、MUGENUPの伊藤です。

今回は先日2/27日に開催した勉強会をまとめてみました!

当日は外部の方を6人お招きし、全部で12人での開催となりました。

f:id:mgnup:20140227213058j:plain

開催までの流れ

MUGENUPの開発部では隔週木曜日に勉強会を開催しています。 元々は少人数でそれぞれの人が好きなことを発表するスタイルで、発表中にツッコミとかしながら和気あいあいとやっていました。

また、年末に外部の人を呼んでやったところ好評で、その後定期的に参加してくれるようになった人もいます。

そんなこんなで「本格的に外部の人を呼んでやってみたい!」と思い、元々MUGENUPと御縁のあったエンジニアの方や、知り合いづてなどで何人かに声をかけてみました。

すると皆さん快諾して頂き、晴れてこの勉強会開催にこぎつけました。

会場

今回はMUGENUPの入っているビルにある「シアタールーム」というところで開催しています。 名前の通り映画鑑賞や、カラオケなんかもできちゃう部屋なんですがソファでくつろげたり照明をいじれたりで結構雰囲気を出せるような部屋になっています。

f:id:mgnup:20140213194143j:plain

当日の内容

当日は19:30 ~ 21:30の2時間で予定していました。 普段は一人あたり5~10分の発表で、発表中にツッコミを入れたりするので一人あたりだいたい15分くらいでのんびりやっていました。

しかし、段々と参加者が増えていき、「あれ、これそもそも発表時間オーバーするかも」などと思っていたのですが、 最終的には発表者が10名程だったことからなんとかギリギリ全員が発表できました。

さて、気になる当日の発表内容ですが、

  • vagrant, chef, knife soloを使ってmunin-nodeを複数サーバーに展開し監視をするまで

  • 酔っぱらいレベルを測定できるアプリNomBay

  • Semantic Versioningについて

  • psd のファイル構造、または photoshop plugin を作る的な何か

  • 楽しくなるPerl開発環境

  • RubyのSymbolについて

  • 電子マネーに関する何か

  • binding.pryに隠れている黒魔術について

といった内容の発表がありました。

各人が好きなことを発表し合った結果、内容がかなりばらつき 普段なら接することはない技術をお互いが知ることができてかなり刺激的な勉強会になりました。

ちなみに私もperlは使ったことがなかったので、その後インストールして色々いじってみました。

社内では「また外部の人を呼んで勉強会をやろう!」という風に盛り上がったので、 また近いうちに開催予定です。

ご興味の有る方は伊藤までご連絡ください!

宣伝

MUGENUP では、知りたがりなエンジニアを募集中です。

無限流開発、ご一緒しませんか?

エントリーはこちら http://mugenup.com/recruit/information#web_application

f:id:mgnup:20131118023234p:plain

Rails 4.1 の spring で paralell_tests を使う方法

みなさん、こんにちは! 2週間ぶりのご無沙汰、MUGENUP の osada です。

ruby 2.1.0Rails 4.1 で開発した、みなゲー編集部が正式リリースとなりました! よろしくお願いします。

さて、そんなRails 4.1の新機能として、プレローダーspringが標準装備となりました。 本日はspringparalell_testsを併用する方法についてのお話です。

要旨は下記となります。

  • PARALLEL_TESTS_EXECUTABLEを設定して、paralell_testsspringを使わせない
  • bin/rspecを書き換えて、始めのプロセスだけspringを使う
  • FailuresLogger を使って、失敗したテストを再実行する
  • RuntimeLoggerを使って、テストのグループを平均化する

では、よろしくお願いします。

PARALLEL_TESTS_EXECUTABLEを設定して、paralell_testsspringを使わせない

Rails 4.1のテストで、rspecでは成功するのに、paralell_testsでは多くが失敗する、という現象に遭いました。 springが原因と考えられます。

通常、rakerspecコマンドを使うとき、コード(code)を読み込んで、メモリ上にRailsappインスタンスを生成し、使用します。処理が終わると、appは解放されます。

f:id:mgnup:20140223195200p:plain

この処理はかなり重い処理なので、毎回作るのではなく、一度作ったappを使いまわそう、というのがspringの動きです。springで作られたappは処理が終わっても維持され、再度使用されます。

f:id:mgnup:20140223195207p:plain

このspringbin_stubとして提供され、bin/というディレクトリに rails, rspec, rakeのコマンドが用意されます。例えば、

be bin/rake db:create

のように、通常のコマンドの代わりに、bin/下のコマンドを呼び出すことで、springが使用されます。

一方、paralell_testsというのは、rakeなどの処理を複数のプロセスに分けて同時に処理する方法です。

f:id:mgnup:20140223195212p:plain

例えば、parallel:specを実行すると、下記の4つのコマンドが実行されます。

bebundle execalias です

$ be rake parallel:spec

TEST_ENV_NUMBER=;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;rspec
TEST_ENV_NUMBER=3;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;rspec
TEST_ENV_NUMBER=4;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;rspec
TEST_ENV_NUMBER=2;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;rspec

しかし、これにspringを使用すると、4つのプロセスがあるのに、実際に稼働するappは1つになってしまいます。

f:id:mgnup:20140223195216p:plain

よって、テストが失敗してしまうのです。

原因はparalell_testsbin/rspecを使用するという点にあります。これをどうにかしましょう。

$ be rake parallel:spec

TEST_ENV_NUMBER=;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;bin/rspec
……

結論からいうと、PARALLEL_TESTS_EXECUTABLEを設定することで、bin/rspecの使用を回避できます。

PARALLEL_TESTS_EXECUTABLE="bundle exec rspec" be rake parallel:spec

TEST_ENV_NUMBER=;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;bundle exec rspec
……

この理由は、parallel_testsは、実行するファイルを下記のように設定しているからです。

def executable
  ENV['PARALLEL_TESTS_EXECUTABLE'] || determine_executable
end

またrspecdetermine_executableは下記であり、[bin/rspec, script/spec, bundle exec rspec, spec, rspec] のどれかを起動する事がわかります。

def determine_executable
  cmd = case
  when File.exists?("bin/rspec")
    "bin/rspec"
  when File.file?("script/spec")
    "script/spec"
  when ParallelTests.bundler_enabled?
    cmd = (run("bundle show rspec-core") =~ %r{Could not find gem.*} ? "spec" : "rspec")
    "bundle exec #{cmd}"
  else
    %w[spec rspec].detect{|cmd| system "#{cmd} --version > /dev/null 2>&1" }
  end

  cmd or raise("Can't find executables rspec or spec")
end

bin/rspecを削除しても良いのですが、それではspringが使えなくなるので、 PARALLEL_TESTS_EXECUTABLEを設定する方が適切でしょう。

以上、本項の結論は、

parallel_testsに、springを使わせないためには、PARALLEL_TESTS_EXECUTABLEを設定する となります。

bin/rspecを書き換えて、始めのプロセスだけspringを使う

しかしせっかくのspringです。 4つの内1つだけでspringを使い、 残り3つを別のプロセスで動かせば良いのではないでしょうか?

f:id:mgnup:20140223195219p:plain

相手はbin_stubですから、修正はとても簡単です。

bin/rspecのこれを、

if !Process.respond_to?(:fork) || Gem::Specification.find_all_by_name("spring").empty?
  exec "bundle", "exec", "rspec", *ARGV
else
  ARGV.unshift "rspec"
  load Gem.bin_path("spring", "spring")
end

こうしましょう!

if !Process.respond_to?(:fork) || Gem::Specification.find_all_by_name("spring").empty?
  exec "bundle", "exec", "rspec", *ARGV
else
  if ENV["TEST_ENV_NUMBER"].nil? || ENV["TEST_ENV_NUMBER"].empty?
    ARGV.unshift "rspec"
    load Gem.bin_path("spring", "spring")
  else
    exec "bundle", "exec", "rspec", *ARGV
  end
end

ENV["TEST_ENV_NUMBER"].nil? は通常のbin/rspec起動時です。 ENV["TEST_ENV_NUMBER"].empty?は、parallel_testsの一番始めのプロセスの時です。

つまり、

bin/rspecとして起動したとき、または、parallel_testsの一番始めのプロセスの時に、springを使用する。

というbin_stubになりました。

さて、肝心のスピードアップですが、featureテストなどが多いため、私のプロジェクトでは違いが現れませんでした。 参考までとはなりますが、環境の影響が少ないspec/modelに適用した結果を載せておきます。

[feature/parallel stash]~/projects/mugenup/workstation: time RAILS_ENV=test PARALLEL_TESTS_EXECUTABLE="bundle exec rspec" bundle exec rake parallel:spec[model]
……
444 examples, 0 failures

Took 216.680576 seconds

real    3m47.661s
user    3m20.585s
sys     0m25.249s
[feature/parallel stash]~/projects/mugenup/workstation: time RAILS_ENV=test bundle exec bin/rake parallel:spec[model]

……
444 examples, 0 failures

Took 212.844729 seconds

real    3m37.395s
user    0m1.378s
sys     0m0.285s

realを見ると10 secほど速くなっているように見えますが、誤差の範囲かもしれません。 もし「速くなったよ!」という方がいらっしゃいましたら、ご一報ください!

FailuresLogger を使って、失敗したテストを再実行する

featureテストは実際のブラウザの挙動を再現するため、 タイムアウトなどの理由により、 タイミングによって失敗することもあります。 parallel_testsを使うと、さらに失敗しやすくなります。

たまたま失敗したのか、本当に失敗しているのかを確認するため、 失敗したテストのみを再度実行するのですが、 その時に役立つのが、FailuresLoggerです。

.rspec_parallelparalell_testsのみのオプションを設定できます。

# .rspec_parallel

--format progress
--format ParallelTests::RSpec::FailuresLogger --out tmp/failing_specs.log

これには失敗したテストがログに残ります。標準出力と同じものです。

f:id:mgnup:20140223200202p:plain

ここから、再度テストを実行しましょう。

$ grep rspec tmp/failing_specs.log | awk '{print $2}' | xargs bundle exec rspec

/Users/osada/projects/mugenup/workstation/vendor/bundle/gems/rspec-core-2.14.7/lib/rspec/core/configuration.rb:896:in `load': cannot load such file -- /Users/osada/projects/mugenup/workstation/spec/views/projects/edit.html.erb_spec.rb:34 (LoadError)

あら、失敗してしまいました。なぜでしょうか? 実際にファイルを開いてみたところ、ascii color codeが含まれていました。

f:id:mgnup:20140223200208p:plain

たしかに、これでは、rspecが通りませんね。 といっても--colorを消してしまうのも、嬉しくありません。

そこで、正規表現を使って、抜き出すことにしました。

  • rspec から始まり、「ドット、スラッシュ、コロン、英数字とアンダースコア」で構成される文字列を抽出する
$ ruby -ne 'puts $1 if /(?<=rspec )([\.\/:\w]+)/' tmp/failing_specs.log | xargs bundle exec rspec

これで、失敗したテストのみ、再実行することができます。

rake taskにするべきか?とも思ったのですが、さらに実行を遅らせてしまいそうなので、 aliasの登録だけにしておきました。

こうゆうとき、どういう技を使えばいいか、わからないの

rubyワンライナーを書くのは、少し大変ですね。

RuntimeLoggerを使って、テストのグループを平均化する

さて、paralell_testsはデフォルトでは、ファイルサイズ順に均等化します。 ファイルサイズの大きい順に並べて、4つのグループに順番に追加していくわけです。

RuntimeLoggerを使うことで、テストにかかった時間で、均等化することができます。

--format progress
--format ParallelTests::RSpec::RuntimeLogger --out tmp/parallel_runtime_rspec.log

tmp/parallel_runtime_rspec.logには、テストファイル名と、その実行時間が記録されています。

[feature/parallel stash]~/projects/mugenup/workstation: tail tmp/parallel_runtime_rspec.log
……
./spec/views/sub_projects/show.html.erb_spec.rb:2.261982
./spec/views/projects/show.html.erb_spec.rb:2.806851

テストのグループ分けのとき、with_runtime_infoメソッドが呼ばれます。 runtime_logつまりtmp/parallel_runtime_rspecがあるとき、かつ、 今回のtestsファイル群が、このログの中に含まれていると考えられるときに、 このログを使ってグループ分けします。

puts "Using recorded test runtime"が出力されれば、成功です。

def with_runtime_info(tests)
  lines = File.read(runtime_log).split("\n") rescue []

  # use recorded test runtime if we got enough data
  if lines.size * 1.5 > tests.size
    puts "Using recorded test runtime"
    times = Hash.new(1)
    lines.each do |line|
      test, time = line.split(":")
      next unless test and time
      times[File.expand_path(test)] = time.to_f
    end
    tests.sort.map{|test| [test, times[File.expand_path(test)]] }
  else # use file sizes
    tests.sort.map{|test| [test, File.stat(test).size] }
  end
end

実際に、使用したときと、使用していないときを比較してみます。

[feature/parallel stash]~/projects/mugenup/workstation: RAILS_ENV=test bundle exec bin/rake parallel:spec[model]

Finished in 2 minutes 19.6 seconds
78 examples, 0 failures

Finished in 3 minutes 19.1 seconds
124 examples, 0 failures

Finished in 3 minutes 10.7 seconds
116 examples, 0 failures

Finished in 3 minutes 13.1 seconds
126 examples, 0 failures

使用していないときの処理時間は2:19.6 〜 3:19.1であり、60秒以上のズレがあります。

一方、runtime_logを使用したときは下記です。 Using recorded test runtimeが表示されていることが確認できます。

[feature/parallel stash]~/projects/mugenup/workstation: RAILS_ENV=test bundle exec bin/rake parallel:spec[model]
Using recorded test runtime

Finished in 3 minutes 7.8 seconds
102 examples, 0 failures

Finished in 2 minutes 58.3 seconds
108 examples, 0 failures

Finished in 3 minutes 10.2 seconds
123 examples, 0 failures

Finished in 3 minutes 11.8 seconds
111 examples, 0 failures

2:58.3 〜 3:11.8であり、13秒ほどのズレでした。

残念ながら、こちらも大きなスピードアップは感じられませんでしたが、 速くなったよ、という方はご一報いただけると嬉しいです。

まとめ

parallel_testsspringの使い方、いかがだったでしょうか?

  • PARALLEL_TESTS_EXECUTABLE="bundle exec rspec" を設定して、paralell_testsspringを使わせない
  • bin/rspecを書き換えて、始めのプロセスだけspringを使う
  • FailuresLogger を使って、失敗したテストを再実行する
  • RuntimeLoggerを使って、テストのグループを平均化する

弊社でもテストが遅いということが、問題になっており、日々改善に勤しんでおります。

こんなやり方あるよ!という情報をお持ちの方、いらっしゃいましたら、 ご教授いただけますと幸いです。

よろしくお願いします!

宣伝

MUGENUP では、rails を使いたいエンジニアを募集中です。 無限流開発、ご一緒しませんか?

大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly

f:id:mgnup:20131118023234p:plain

【探検】Railsカラム更新のメソッド1

初めに

皆さん、初めまして。そして、明けましておめでとうございます。株式会社MUGENUP 開発部の奥田です。 今回、初めて技術ブログを書くことになりました。よろしくお願い致します。

何回かに分けてRailsのカラム更新メソッドについて書いていき、今回はupdate_attributeupdate_attributesについて記述します。2つのメソッドは私の環境だとactiverecord-3.2.16/lib/active_record/persistence.rbにありました。以下の表は簡単なまとめです。

メソッド名 validation callback 更新カラム数
update_attribute なし あり 1つだけ
update_attributes あり あり 複数

それではそれぞれのメソッドのソースコードを探検してみましょう〜!!

ActiveRecord::Persistence#update_attribute

  # Updates a single attribute and saves the record.
  # This is especially useful for boolean flags on existing records. Also note that
  #
  # * Validation is skipped.
  # * Callbacks are invoked.
  # * updated_at/updated_on column is updated if that column is available.
  # * Updates all the attributes that are dirty in this object.
  #
  def update_attribute(name, value)
    name = name.to_s
    raise ActiveRecordError, "#{name} is marked as readonly" if self.class.readonly_attributes.include?(name)
    send("#{name}=", value)
    save(:validate => false)
  end

save(:validate => false)となっているので、validationなしということがわかります。Callbackを起こすは英語でinvokeと表現するのですね〜。updated_atまたはupdated_onは更新するとわざわざ記述しているので、更新しないメソッドもあるのかもしれません。驚いたのはsend("#{name}=", value)の部分で、sendメソッドはカラム名=をメソッドと認識することです。sendメソッドを深堀りするのも面白そうだと思いました。

ActiveRecord::Persistence#update_attributes

  # Updates the attributes of the model from the passed-in hash and saves the
  # record, all wrapped in a transaction. If the object is invalid, the saving
  # will fail and false will be returned.
  #
  # When updating model attributes, mass-assignment security protection is respected.
  # If no +:as+ option is supplied then the +:default+ role will be used.
  # If you want to bypass the protection given by +attr_protected+ and
  # +attr_accessible+ then you can do so using the +:without_protection+ option.
  #
  def update_attributes(attributes, options = {})
    # The following transaction covers any possible database side-effects of the
    # attributes assignment. For example, setting the IDs of a child collection.
    with_transaction_returning_status do
      self.assign_attributes(attributes, options)
      save
    end
  end

update_attributesにはoptionsがあるんですね〜。:without_protectionoptionsに入れることでmass-assignmentのprotectionチェックなしにできることを初めて知りました。assign_attributesの中も見たい気持ちはありますが、今後のネタとして取っておきます(笑)。コメントアウトで強調したいところを+で囲っていますが、私の知らない記法でした。Markdown記法ではないようなので、要調査です。

番外編 ActiveRecord::Persistence#update_attributes!

#update_attributessaveを呼んでいるのでカラム更新の失敗時に戻り値としてfalseを返します。そして、#update_attribute!をつけた#update_attributes!の場合はカラム更新の失敗時に例外を返しますが、その中身はどうなっているのでしょう?saveが呼ばれて、!がついてるということは・・・?

  # Updates its receiver just like +update_attributes+ but calls <tt>save!</tt> instead
  # of +save+, so an exception is raised if the record is invalid.
  def update_attributes!(attributes, options = {})
    # The following transaction covers any possible database side-effects of the
    # attributes assignment. For example, setting the IDs of a child collection.
    with_transaction_returning_status do
      self.assign_attributes(attributes, options)
      save!
    end
  end

予想通りですが、save!が呼ばれていました〜。

最後に

簡単にではありましたが、Railsのカラム更新メソッドについて見てきました。この記事を読んで初めて知ったことがあれば幸いです。他にもカラム更新のメソッドはあるので、私の次回の記事でもカラム更新のメソッドを探検したいと思います。

宣伝

MUGENUP では、Rails を使いたいエンジニアを募集中です。 無限流開発、ご一緒しませんか?

大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly

f:id:mgnup:20131118023234p:plain