【rails 小ネタ】pluck でOUTER JOINしたテーブルの情報を取得
User.joins(:address).pluck(:id, "addresses.city")
# SELECT
#   useres.id, addresses.city
# FROM
#   users
# LEFT OUTER JOIN
#   addresses ON useres.address_id = addresses.id;

User.includes(:address).pluck(:id, "addresses.city")
# SELECT
#   useres.id, addresses.city
# FROM
#   users
# LEFT OUTER JOIN
#   addresses ON useres.address_id = addresses.id;

 こんな感じで取得できます。
 pluck に渡す引数は SQL の SELECT 句 に書く感じで書けばOKです。

 同じテーブルに複数回 JOIN した場合、最初に出てきたカラム名は元のテーブル名のままでいいのですが、2つ目以降はちょっと癖があります。
 具体例を書いておきますね。

User.joins(:shipment_address, :company_address).pluck(:id, "addresses.city", "company_addresses_users.city")
# SELECT
#   useres.id, addresses.city, company_addresses_users.city
# FROM
#   users
# LEFT OUTER JOIN addresses ON useres.shipment_address_id = addresses.id
# LEFT OUTER JOIN addresses AS company_addresses_users ON users.shipment_address_id  = company_addresses_users.id;

 あとから参照されたカラムのリレーション先のテーブル名は

 pluckで指定した文字列の複数形 + 大本のテーブル名

 という名前になってます。忘れると仕事に支障が出るので書き残しておく次第です。

Rails 4.1 MiniTest 導入

 今までRailsのテストには Rspec を使っていたのですが、今回はMiniTestを使うことになりました。
 上司の鶴の一声的なものだったので、頑張ればひっくり返せたかもしれませんが、せっかくの機会だったのでMiniTestの使い方を習得してみようと実装を始めました。

 が……

 日本語の資料がとにかく少ない……。
 びっくりするぐらい少ないので、基本的なところをさら~~っとまとめておこうと思います。

前提

 プロジェクトの途中でMiniTestを導入した体で進めていきます。
 アプリケーションを作る時からMiniTestの導入が決まっている場合は下記ページのほうが役に立つと思います。
 Railsテスティングガイド

インストール

 Gem (minitest と minitest-rails)をインストールしたら、まずは rails generate minitest:install コマンドを実行します。
 するとアプリケーションルートに test ディレクトリが生成され、その中に test_helper.rb ファイルが作られます。

テストファイルの作成

 すでにプロジェクトの製作が進んでおり、ひと通りモデルなどが設計されている状態からモデルのテストファイルを作るには、rails generate minitest コマンドを使います。

rails generate minitest:model Sample

 上記具体例であれば /test/models/sample_test.rb ファイルと /test/fixtures/samples.yml ファイルが作成されます。
 作成されたファイルの内容は以下のとおりです。

require "test_helper"

class SampleTest < ActiveSupport::TestCase

  def sample
    @sample ||= Sample.new
  end

  def test_valid
    assert sample.valid?
  end

end
# Read about fixtures at
# http://api.rubyonrails.org/classes/ActiveRecord/Fixtures.html

# This model initially had no columns defined.  If you add columns to the
# model remove the '{}' from the fixture names and add the columns immediately
# below each fixture, per the syntax in the comments below
#
one: {}
# column: value
#
two: {}
#  column: value

fixture を編集

 次に fixture を編集します。これはテスト時に使用するDBの値になります。
 sample テーブルに int 型の test があるなら、下のように書きます。

sample_1:
  test: 1
  created_at: <%= Time.now %>
  updated_at: <%= Time.now %>

 created_at と updated_at はある前提で書きました。
 view と同じように ruby のコードを埋め込むことも可能です。

テスト作成

 /test/models 配下のファイルを編集して、テストを書いていきます。
 記述方法は非常にシンプルで、中でクラスを初期化して該当メソッドを実行するだけ。命名規約も、メソッド名に test_ を接頭語としてつけるだけ。前準備は setup メソッドに、後処理は teardown メソッドに記述します。
 具体例を書きます。

require "test_helper"

class SampleTest < ActiveSupport::TestCase

  def setup
    # 前処理
    @sample = samples(:sample_1)
  end

  def teardown
    # 後処理
  end

  def test_sample
    assert_equal @sample.test, 1
  end

end

 4行目の samples に渡しているシンボル「:sample_1」は samples.yml の1行目に書いたやつです。
 内容は単純に前処理で生成した @sample の test を 1 と比較し、assert_equal メソッドを使って同じ値かを確認しています。
 MiniTest では assert メソッドでテスト結果を評価します。

assert BOOLEAN                # 実行結果が真なのかチェック
assert_equal VALUE_a, VALUE_b # 2つの値が同じかチェック
assert_empty ARRAY            # 中身が空かチェック
refute BOOLEAN                # 実行結果が偽なのかチェック
refute_equal VALUE_a, VALUE_b # 2つの値が異なっているかチェック

テスト実行

 rake test コマンドを実行してあげればOKです。
 テスト結果を見ながらテストを追加したり、直してあげたりしましょう。
 

備考

 テストファイルを作る際、名前空間がある場合はモデル作る時と同じで :: を使えば指定できる。

rails generate minitest:model NameSpace::Sample

 これで /test/models/name_space/sample_test.rb と /test/fixtures/name_space/samples.yml が生成されます。

 ……色々書いてみましたが、だいたいのことは GitHub に書いてあったりするんですよね。

Rails の CookieStore をデコード

いやー、意外と苦労しました。

第一稿は、オブジェクトが初期化出来なかったので破棄し、
第二稿は、初期化に必要な情報が直書きだったので書き直し、
これが第三稿です。

secret = ActiveSupport::CachingKeyGenerator.new Rails.application.key_generator
encryptor = ActiveSupport::MessageEncryptor.new secret.generate_key(Rails.application.config.action_dispatch.encrypted_cookie_salt),
                                                secret.generate_key(Rails.application.config.action_dispatch.encrypted_signed_cookie_salt),
                                                serializer: ActionDispatch::Cookies::NullSerializer
encryptor.decrypt_and_verify CGI.unescape(cookie_str)

 業務でクッキーストアの情報を知る必要が出てきたので、デコード方法を探したのですが…
 どれもうまくいきませんでした。

 情報が古かったのか、何なのか、原因は不明ですが、とにかく急ぎで対応する必要があったのでgem内を色々漁ってなんとかしてみました。

 ちなみに業務での Rails のバージョンは 4.1.8 です。

 クッキーストアの値はCookieのRails.application.config.session_options[:key]で取得できるキーにエンコードされて格納されており、こいつをデコードする必要があります。
 デコードは ActiveSupport::MessageEncryptor オブジェクトの decrypt_and_verify メソッドでできるのですが、初期化するためにはシリアライザークラスと、encrypted_cookie_salt と encrypted_signed_cookie_salt を元に作られた key が必要になるので、まずはキーを作りたいのですがそれには ActiveSupport::CachingKeyGenerator オブジェクトが必要で、これの初期化には ActiveSupport::KeyGenerator が必要です。
 さらに ActiveSupport::KeyGenerator の初期化には secret キーと iterations の値が必要ということがわかりました。

 整理すると、下記クラスの初期化が必要です。

  1. ActiveSupport::KeyGenerator
  2. ActiveSupport::CachingKeyGenerator
  3. ActiveSupport::MessageEncryptor

 初期化のためには下記の情報が必要です。

  1. secret キー
  2. iterations
  3. encrypted_cookie_salt
  4. encrypted_signed_cookie_salt
  5. シリアライザークラス

 情報が多くて面倒ですが、一つ一つ順を追って確認していきます

secret キー

 Rails.application.secrets[:secret_key_base] で取得可能であることが判明。
 (設定されている値は /config/secrets.yml の該当する環境の値)

iterations

 iterations の値がどうやっても見つからない。
 設定しないで ActiveSupport::KeyGenerator を初期化することも可能だったが、それだと iterations の値が 65536 に指定されてしまう。
 ”何故か” Rails デフォルトだと、iterations の値は1000になっているので、なんとかしてこの設定値を探しだそうと躍起になった結果…

 Rails.application.key_generator に初期化済みのオブジェクトを見つけましたっ!
 この初期化済みのオブジェクトを作成するときに 1000 の値を使っていて、クッキーストアの値をエンコードする時にはこのオブジェクトを使っているようです。

ActiveSupport::KeyGenerator オブジェクト

 上記記載の通り Rails.application.key_generator に初期化済みのオブジェクトが有るためこれをそのまま使います。
 …何のために secret キーとか調べたのか。

encrypted_cookie_salt

 デフォルト値だと encrypted cookie になっているようですが、これの設定はどこに有るんだろうと探したところ、…/lib/action_dispatch/railtie.rb (ver.4.1.8) に記述が有りました。
 ここには config.action_dispatch.encrypted_cookie_salt = ‘encrypted cookie’ とあったので Rails.application.config.action_dispatch.encrypted_cookie_salt で取れるみたいです。

encrypted_signed_cookie_salt

 encrypted_cookie_salt と同じ所に有りました。
 ちなみにデフォルト値は signed encrypted cookie です

シリアライザークラス

 これの指定はクッキーストアに値を格納するために使われている ActionDispatch::Cookies::EncryptedCookieJar クラスのコンストラクタに記載がありました。
 具体的には …/lib/action_dispatch/middleware/cookies.rb (ver.4.1.8) の 511 行目にあります。

        secret = key_generator.generate_key(@options[:encrypted_cookie_salt])
        sign_secret = key_generator.generate_key(@options[:encrypted_signed_cookie_salt])
        @encryptor = ActiveSupport::MessageEncryptor.new(secret, sign_secret, serializer: NullSerializer)
      end

      def [](name)

 ココで ActionDispatch::Cookies::NullSerializer を直値で渡しているため、もうこれは直書きです。

ActiveSupport::MessageEncryptor

 ActiveSupport::MessageEncryptor に encrypted_cookie_salt と encrypted_signed_cookie_salt と シリアライザークラス を渡して初期化すればOKです。

最後に

 クッキーストアの値は HTML エンコードされているので CGI.unescape メソッド使ってデコードするのを忘れないようにしてください。
 僕は忘れて2時間ぐらいハマってました…

ActiveRecord_Associations_CollectionProxy ってなんだよ!

rails でだいぶ無茶しなきゃいけないことがあったので備忘録

* * *

 ECサイトのカスタマイズで見積書を出す機能を追加したのですが、合計金額算出時に、ActiveRecord の sum メソッドを使用していたため見積りの内容を「注文として」一度DBに保存しないといけないという事実が発覚。
(※ sum メソッドは、”SELECT count(メソッド引数) FROM テーブル名” というクエリを発行するため、保存していないと 0 が返ってくる)
 注文として保存してしまうとログに残ったり、見積書作成中に下手にページ遷移されると危険なので sum メソッドの挙動を変更することにしたのですが、sum メソッドはいろいろなところで使ってるためモンキーパッチを当てるわけにも行かない。
 かと言って同じメンバを持つダミークラスを作ろうとすると、今度はせっかくフレームワークに実装されている消費税計算・経費計算が使えなくなってしまう。
 それらまで移植するのはゼッタイに嫌だったので、見積り算出時のみ sum メソッドでクエリを発行しないようにするコードをガリガリと書いてみました。

 それがこちら

class DummyOrder < Order
  self.table_name = 'orders'

  def initialize
    super nil, {}

    # LineItem モデルのリレーションを上書きする
    association_instance_set :line_items,
                             DummyLineItem::DummyHasManyAssociation.new(self, self.class.reflections[:line_items])
  end
end

# 見積書作成時に使うダミーLineItem
class DummyLineItem < LineItem
  self.table_name = 'line_item'

  # LineItem の CollectionProxy を騙す
  class DummyHasManyAssociation < ActiveRecord::Associations::HasManyAssociation
    def reader(force_reload = false)
      if force_reload
        klass.uncached { reload }
      elsif stale_target?
        reload
      end

      @proxy ||= DummyCollectionProxy.new DummyLineItem, self
    end
  end

  # sum メソッドを DB の呼び出しから、自身の持つ配列から算出するよう修正
  class DummyCollectionProxy < LineItem::ActiveRecord_Associations_CollectionProxy
    def sum(args)
      case args.to_s
      when "price * quantity"
        reduce(0) { |a, e| a + e.price * e.quantity }
      else
        reduce(0) { |a, e| a + e.send(args.to_s).to_i }
      end
    end
  end
end

…カオス。

一応解説を書いておくとですね、
 OrderモデルとLineItemモデルが1対多関係にあり、Orderモデルが line_items メソッドを持っているのですが、initialize で association_instance_setを呼び出し、line_items メソッドで返される LineItem::ActiveRecord_Associations_CollectionProxy インスタンスを DummyLineItem::DummyHasManyAssociation インスタンスに置き換えます。
 ダミークラス「DummyHasManyAssociation」のreaderは、元の「ActiveRecord::Associations::HasManyAssociation」クラスのreaderをほぼ丸コピーなのですが、最後の1行は書き換えてます。

@proxy ||= DummyCollectionProxy.new DummyLineItem, self

コレのことです。

ちなみに、元々のコードは下記のとおりです。
(activerecord-4.1.6/lib/active_record/associations/collection_association.rb)

@proxy ||= CollectionProxy.create(klass, self)

 ざっくり追った感じ、Reflection オブジェクト(has_manyとか指定すると自動的に作成されるリレーション用のオブジェクト)から、対象のテーブルに紐付いたモデルオブジェクトのCollectionProxyインスタンスを作成して返す、という挙動をしているみたいですが、今回は特定条件下でしか使わない前提だったので今回作ったDummyCollectionProxyインスタンスを返すようにしてます。
 これをやらないとActiveRecord が自動生成したCollectionProxyが生成されてしまいます。
(self.class.reflections で呼び出している Reflection オブジェクトの中身を書き換えられればいいのですが、コレは実態のあるテーブルと密に紐付いており、変更が面倒臭かった今回のケースではそこまで手を入れる必要がなかったのでHasManyAssociationを置き換える方法を取りました)

 最後に、DummyCollectionProxy の sum メソッドをオーバーライドしてやればOKです。

 見積り作成時にはこのDummyOrder を生成してやればDBを使うことなく合計値が出せるようになりました。

…もっとスマートな方法があったら誰か教えてください。

Spree で、slug の自動作成を防止する方法

ruby on rails で作られたCMS、Spree の管理画面から商品を新規作成する際、Slug は商品名称(name)と品番(sku)から英数字だけをつなぎあわせて自動生成されますが、
日本語の品名だけだと、Slug が空になりバリデーションエラー(3文字以上入力してください)になってしまいます。

回避方法としては

  • 日本語のslugを許容させる
  • slug の入力項目を作る。

のどちらかです。
ただし、後者で対処する場合、入力項目を作っても商品名称(name)と品番(sku)で強制的に上書きされてしまい、入力した項目は反映されません。
それを回避するには、Spree::Producrt のDecoratorを作って下記コードを埋め込めば回避できます。

/app/models/spree/product_decorator.rb

Spree::Product.class_eval do
  ...
  def slug_candidates_with_slug
    [:slug]
  end
  alias_method_chain :slug_candidates, :slug
end

こんな感じのコードです。

これは、spree core の product モデルに、フレンドリURLを実現するために friendly_id が呼ばれているためです。

    friendly_id :slug_candidates, use: :slugged

ちなみに、slug_candidates メソッドの中身は下記のようになってます。

    # Try building a slug based on the following fields in increasing order of specificity.
    def slug_candidates
      [
          :name,
          [:name, :sku]
      ]
    end

これを decorator で上書き(正確には、メソッドに別の名前をつけて呼び出しを回避)したわけです。

はぁ、spree は日本語の資料が少ないので大変だ…

RSS / feedly
  • follow us in feedly
  • follow us in feedly
ソーシャル
広告