Rails の to_json には便利なオプションを渡せるよ

3行まとめ

Rails でDBのデータを取得して json 形式に変換したい、というシーンにおいて

  • to_jsonActiveSupport::ToJsonWithActiveSupportEncoder のメソッド
  • to_json には as_json (ActiveModel::Serializers::JSON) のオプションが渡せる
  • as_json のオプションはとても便利

単に json 形式に変換したい

Rails でのアプリケーション開発において、取得してきたデータを json 形式に変換したいシーンはままあると思う。 そういう時は to_json が便利。

例えば name に tanaken を含むユーザを取得して json 形式に変換したい時はこんな感じ。

> users = User.where('users.name like "%tanaken%"')
=> [#<User:0x00007faead5daf08
  id: 1,
  name: "tanaken0515",
  email: "tanaken0515@example.com",
  created_at: Wed, 05 Feb 2020 21:25:55 JST +09:00,
  updated_at: Wed, 05 Feb 2020 21:28:07 JST +09:00>]
> users.to_json
=> "[{\"id\":1,\"name\":\"tanaken0515\",\"email\":\"tanaken0515@example.com\",\"created_at\":\"2020-02-05T21:25:55.000+09:00\",\"updated_at\":\"2020-02-05T21:28:07.000+09:00\"}]"

便利。

モデルの特定のカラムだけ欲しい

でもこれだと User モデルのカラムを全部出しちゃって不便なことがある。例えば「この json をフロントに渡したいんだけど email は個人情報だから渡したくない。id と name だけあれば良い。」みたいな時。

実際のお仕事(主に土日にやってるほう)で似たようなシーンがあったのだけど、先日まで to_json を雰囲気で使っていた( json 形式に変換してくれるだけでしょ?と思っていた)自分は、「 select で絞ってから to_json すっか〜」というアプローチをとった。

> users = User.select(:id, :name).where('users.name like "%tanaken%"')
=> [#<User:0x00007faead64f5b0 id: 1, name: "tanaken0515">]
> users.to_json
=> "[{\"id\":1,\"name\":\"tanaken0515\"}]"

そしたらレッビューで「それ to_jsononly: オプションを指定したらできますよ〜」というコメントをいただいた。

> users = User.where('users.name like "%tanaken%"')
=> [#<User:0x00007faead5daf08
  id: 1,
  name: "tanaken0515",
  email: "tanaken0515@example.com",
  created_at: Wed, 05 Feb 2020 21:25:55 JST +09:00,
  updated_at: Wed, 05 Feb 2020 21:28:07 JST +09:00>]
> users.to_json(only: [:id, :name])
=> "[{\"id\":1,\"name\":\"tanaken0515\"}]"

なるほど便利。ありがたレビュー、大感謝太郎。

to_json ってなんなのよ

で、この to_json ってなんなのよ、というお話。調べますわよ。

まずは rails console で定義元を探してみる。 source_location が便利。

> users.method(:to_json).source_location
=> ["/path/to/project/root/vendor/bundle/ruby/2.6.0/gems/activesupport-6.0.2.2/lib/active_support/core_ext/object/json.rb", 36]

https://github.com/rails/rails/blob/9256ae8a389fd40f9e4f152737de0fb2c6059daf/activesupport/lib/active_support/core_ext/object/json.rb#L36 ここ。

to_jsonActiveSupport::ToJsonWithActiveSupportEncoder モジュールのメソッドだったのだなぁ。(# :nodoc: となっているのでドキュメントはない)

実は rails console でメソッドの中身をみることもできて便利(これは最近知った).

> users.method(:to_json).source.display
    def to_json(options = nil)
      if options.is_a?(::JSON::State)
        # Called from JSON.{generate,dump}, forward it to JSON gem's to_json
        super(options)
      else
        # to_json is being invoked directly, use ActiveSupport's encoder
        ActiveSupport::JSON.encode(self, options)
      end
    end
=> nil

さっきの例で渡しているオプション only: [:id, :name] はただの Hash なので↑の else の方に入って、ActiveSupport::JSON.encode(self, options) が動くわけですな。


さて、続いて ActiveSupport::JSON.encode(self, options) について調べる。何も考えずに以下を打つ。

> ActiveSupport::JSON.method(:encode).source_location
=> ["/path/to/project/root/vendor/bundle/ruby/2.6.0/gems/activesupport-6.0.2.2/lib/active_support/json/encoding.rb", 21]
> ActiveSupport::JSON.method(:encode).source.display
    def self.encode(value, options = nil)
      Encoding.json_encoder.new(options).encode(value)
    end
=> nil

Encoding.json_encoder がなんらかのクラスで、そのインスタンスを new して encode メソッドを呼んでる。

https://github.com/rails/rails/blob/144df5b104d042792f3fa73576d3ca9fac74fa67/activesupport/lib/active_support/json/encoding.rb#L21 この辺読むと、Encoding はすぐ下にモジュールとして定義されていて、このモジュール内の一番下に json_encoder が書いてある。

ざっくりこんな感じ

module Encoding #:nodoc:
  class JSONGemEncoder #:nodoc:
    # 略
  end
  # 略
  
  class << self
    # 略
    attr_accessor :json_encoder
    # 略
  end
  
  self.json_encoder = JSONGemEncoder
  # 略
end

となってる。

つまり Encoding.json_encoder.new(options).encode(value)
Encoding::JSONGemEncoder.new(options).encode(value) のこと。

JSONGemEncoder クラスの中身、 encode メソッドを見てみる。

GitHubで直接コード読めば良いんだけどあえて rails console でやるならこう。

> ActiveSupport::JSON::Encoding::JSONGemEncoder.new(only: [:id, :name]).method(:encode).source.display
        def encode(value)
          stringify jsonify value.as_json(options.dup)
        end
=> nil

valueas_json(options.dup) して jsonify したものを stringify するよ、ということですね、直感的ですね。

value ってなんだっけ?というと ActiveSupport::JSON.encode(self, options)self で、これは詰まり users.to_jsonusers ですわ。

なので詰まるところ

> users.to_json(only: [:id, :name])
=> "[{\"id\":1,\"name\":\"tanaken0515\"}]"
> ActiveSupport::JSON::Encoding::JSONGemEncoder.new(only: [:id, :name]).encode(users)
=> "[{\"id\":1,\"name\":\"tanaken0515\"}]"

ってこと。

to_json がなんなのかわかってすっきり。

指定できるオプションは、どこを見ればわかるのよ

to_json の正体がわかったところで、オプションをみていこう。

valueas_json(options.dup) しているので、 as_json で使えるオプションをそのまま使えるっちゅうことですな。

value.as_json は実質 users.as_json なので、rails console でみてみると

> users.method(:as_json).source_location
=> ["/path/to/project/root/vendor/bundle/ruby/2.6.0/gems/activerecord-6.0.2.2/lib/active_record/relation/delegation.rb", 85]
> users.method(:as_json).source.display
    delegate :to_xml, :encode_with, :length, :each, :join,
             :[], :&, :|, :+, :-, :sample, :reverse, :rotate, :compact, :in_groups, :in_groups_of,
             :to_sentence, :to_formatted_s, :as_json,
             :shuffle, :split, :slice, :index, :rindex, to: :records
=> nil

ほ〜ん、なるほど、 recordsdelegate されているのね。

recordsActiveRecord::Relation#records かな。こいつの返り値は Array なので、結局のところ Array#as_json を追えば良さそう。

> users.class
=> User::ActiveRecord_Relation
> users.records.class # 一応返り値のクラスを確かめてみた
=> Array
> users.records.method(:as_json).source_location
=> ["/path/to/project/root/vendor/bundle/ruby/2.6.0/gems/activesupport-6.0.2.2/lib/active_support/core_ext/object/json.rb", 152]
> users.records.method(:as_json).source.display
  def as_json(options = nil) #:nodoc:
    map { |v| options ? v.as_json(options.dup) : v.as_json }
  end
=> nil

これは Array の各要素に対して as_json をしているだけですな。

今回の場合 users の各要素は User モデルのインスタンスなので

> users.first.method(:as_json).source_location
=> ["/path/to/project/root/vendor/bundle/ruby/2.6.0/gems/activemodel-6.0.2.2/lib/active_model/serializers/json.rb", 89]
> users.first.method(:as_json).source.display
      def as_json(options = nil)
        root = if options && options.key?(:root)
          options[:root]
        else
          include_root_in_json
        end

        hash = serializable_hash(options).as_json
        if root
          root = model_name.element if root == true
          { root => hash }
        else
          hash
        end
      end
=> nil

なるほど、ActiveModel::Serializers::JSON モジュールのメソッドだったのだなぁ! そしてこれは # nodoc ではなく、しっかりとドキュメントが書かれているのである!

api.rubyonrails.org

ここに書かれているオプションは使い放題!便便便利!

オプション紹介

ドキュメントのサンプルコードをそのまま貼って眺める

まずはオプション指定なしパターン

user = User.find(1)
user.as_json
# => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
#      "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true}

次に :only , :except 。良いすね。でも個人的には多分 :except は使わないかな〜。ブラックリスト方式よりもホワイトリスト方式の方が安心できるから。センシティブな情報が入ったカラムを後から追加した時に、ブラックリストに指定し忘れるとまずいからね〜。

user.as_json(only: [:id, :name])
# => { "id" => 1, "name" => "Konata Izumi" }

user.as_json(except: [:id, :created_at, :age])
# => { "name" => "Konata Izumi", "awesome" => true }

続いて :methods これ便利すな〜。

user.as_json(methods: :permalink)
# => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
#      "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true,
#      "permalink" => "1-konata-izumi" }

最後に :include 、associationを引いてこれるのも便利だなぁ。

user.as_json(include: :posts)
# => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
#      "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true,
#      "posts" => [ { "id" => 1, "author_id" => 1, "title" => "Welcome to the weblog" },
#                   { "id" => 2, "author_id" => 1, "title" => "So I was thinking" } ] }

3行まとめ(再)

Rails でDBのデータを取得して json 形式に変換したい、というシーンにおいて

  • to_jsonActiveSupport::ToJsonWithActiveSupportEncoder のメソッド
  • to_json には as_json (ActiveModel::Serializers::JSON) のオプションが渡せる
  • as_json のオプションはとても便利

最初に想定した分量の5倍くらいの長さになってしまった。でも勉強になったから、よしっ!