patorashのブログ

方向性はまだない

ActiveStorageで画像を追加アップロードする

さっきこんな記事を書きました。

patorash.hatenablog.com

今度は、更新で画像を追加しようとしたところ、上書きされてしまいました。

追加されると思っていたのでびっくりな挙動でしたが、その辺りはこのissueにあります。

github.com

Rails5までは、追加がデフォルトの動作でしたが、Rails6からは上書きがデフォルトの動作になります。

Rails5の動作と同じにしたい場合は、config/application.rbなどで、以下の設定が必要です。

config.active_storage.replace_on_assign_to_many = false

しかし、これは設定せずに、せっかくRails6で上書きがデフォルトの動作なので、それで対応したいと思います。

対応方法

実は先ほどのissueのコメントの中に対応方法がありました。

https://github.com/rails/rails/issues/35817#issuecomment-484158884

前回の記事のビューに追加で書くとすれば、こう。

.mb-3
  = f.label :images
  = f.file_field :images, direct_upload: true, multiple: true, accept: 'image/*'
  - @post.images.each do |image|
    = f.hidden_field :images, multiple: true, value: image.signed_id

signed_idを付けることで、追加アップロードにできるようになりました。

新たな不具合

しかし、新たな不具合が起きました。 ファイルを追加しつつ、既存の画像を削除しようとしたら、エラーになりました。

qiita.com

これと同じようなエラーなのですが、@post.imagesを追加してから削除して…という途中で、hidden_fieldに入れたsigned_idでは追加しようとするのに、削除フラグがONになっているからおかしくなるのでしょう。 これに関しては、スマートな回避方法を思いつきませんでした。

苦肉の策

Rails6からは上書き保存なところを利用して、削除対象のimageのsigned_idを取得して、それを上書き対象から除外しました。コントローラーのstrong_parametersでの対応となります。

class PostsController < ApplicationController
  # 略

  private

  def post_params
    permit_params = params.require(:post).permit(
        :title,
        :content,
        images: [],
        images_attachments_attributes: [ :id, :_destroy ],
    )
    images_attachments_attributes = permit_params.delete(:images_attachments_attributes)
    if images_attachments_attributes
      destroy_signed_ids = images_attachments_attributes.to_h.map do |_, attribute|
        @post.images.find_by(id: attribute[:id])&.signed_id if attribute.delete(:_destroy)
      end.compact
      permit_params[:images] -= destroy_signed_ids
    end
    permit_params
  end
end

これで、ファイルを追加しつつ、ファイルを削除可能になりました👍

まとめ

ActiveStorageは難しい…。

ActiveStorageで添付した画像をupdateで削除する

Rails 6.0でとあるアプリを作っているのですが、ActiveStorageを使って画像を添付しています。登録は比較的簡単に行えたのですが、画面から画像のみ削除する方法がいまいちわからなかったので調査しました。

コード(修正前)

モデル

複数の画像が添付できるように、has_many_attachedにしています。

class Post < ApplicationRecord
  has_many_attached :images
end

ビュー

simple_formを使っています。画像はActiveStorageのダイレクトアップロードでアップロードしています。

= simple_form_for(@post) do |f|
  = f.error_notification

  .form-inputs
    = f.input :title
    = f.input :content
    .mb-3
      = f.label :images
      = f.file_field :images, direct_upload: true, multiple: true, accept: 'image/*'
  .form-actions
    hr
    = f.button :submit, data: {disable_with: t('helpers.button.disable_with')}, class: 'btn-primary mr-2'
    = link_to t('helpers.links.cancel'), posts_path, class: 'btn btn-secondary'

コントローラー

特に変わりはないのでstrong_parametersのところだけ書いておきます。

class PostsController < ApplicationController
  # 略

  private

  def post_params
    params.require(:post).permit(
        :title,
        :content,
        images: [],
    )
  end
end

やろうとしたアイデア

こういうモデルの子になるデータの追加・削除はaccepts_nested_attributes_forを使うのが順当だろうと思いました。

試したこと(失敗したやつ)

モデルを以下のように修正。

class Post < ApplicationRecord
  has_many_attached :images
  accepts_nested_attributes_for :images, allow_destroy: true
end

ビューを以下のように修正。

= simple_form_for(@post) do |f|
  / 略
  .form-inputs
    / 略
    .mb-3
      = f.label :images
      = f.file_field :images, direct_upload: true, multiple: true, accept: 'image/*'
    .row.mb-3
      = f.fields_for :images do |image|
        .col.col-sm-6.col-md-4.mb-3
          = image.hidden_field :id
          .form-check
            label
              = image.check_box :_destroy, { class: 'form-check-input mr-1', checked: false }, true, false
              | 削除する
          = image_tag url_for(image.object), class: 'img-thumbnail rounded'
  / 略

こうすると、accepts_nested_attributes_for:imagesなんていう関連はない!と言われて落ちます。

しかし、AcitveStorage::Attachmentモデルと関連があるはず…なので、次を試みます。

試したこと(成功したやつ)

モデルを以下のように修正。

class Post < ApplicationRecord
  has_many_attached :images
  accepts_nested_attributes_for :images_attachments, allow_destroy: true
end

ビューを以下のように修正。

= simple_form_for(@post) do |f|
  / 略
  .form-inputs
    / 略
    .mb-3
      = f.label :images
      = f.file_field :images, direct_upload: true, multiple: true, accept: 'image/*'
    .row.mb-3
      = f.fields_for :images_attachments do |image|
        .col.col-sm-6.col-md-4.mb-3
          = image.hidden_field :id
          .form-check
            label
              = image.check_box :_destroy, { class: 'form-check-input mr-1', checked: false }, true, false
              | 削除する
          = image_tag url_for(image.object), class: 'img-thumbnail rounded'
  / 略

コントローラーもstrong_parametersを修正します。

class PostsController < ApplicationController
  # 略

  private

  def post_params
    params.require(:post).permit(
        :title,
        :content,
        images: [],
        images_attachments_attributes: [ :id, :_destroy ],
    )
  end
end

これで、更新時にファイルの削除ができるようになりました。

動作検証

紫陽花と桜の画像をアップ済みです。そこから、紫陽花の画像を削除してみます。

f:id:patorash:20201202154337p:plain

showの画面を都合で見せられないのですが、再び更新画面にきたときには桜の画像のみ残っています。

f:id:patorash:20201202154425p:plain

削除に成功です。

言葉の遅れが改善する方法を読んだ

長男が発達障害と診断され、療育手帳を取得していて、療育施設にも通っている状態です。

うちの子はまだ言葉の獲得ができていないので、それのヒントがあればと思い、購入。

本を読んでほしいので本の内容について書きすぎませんが、内容を見ると本当に身に覚えがありすぎる…。小さな頃からテレビ漬けになってしまっていたのだなぁと思うし、そのせいで人の話言葉を聞く能力が育っていないというのはめちゃめちゃ腑に落ちました。自分達の呼びかけが、テレビの音と同じ雑音だと判断されているとは思いもよりませんでした。

発達障害児の増え方についてもデータで語られていて、昔は5,000人に1人だったのが、今は100人に1人という割合になっている等、激増しているのがわかります。

アナログ子育てにしましょう、と綴られており、現代にとってかなりの難題ではありますが、常になにかしらの音や光に晒されるようなことをやめればいいわけだし、工夫次第でなんとかなると思うので、テレビは当分封印し、スマホもなるべく控えるように、絵本を増やして読み聞かせして、前よりももっと一緒に遊ぶようにしていこうと思います。

これは、著者の片岡先生のサイトに掲載されていたYouTubeの動画。


テレビの子守は危ない!自閉症と診断された言葉遅れの子どもたち

Rails6.0にアップグレードしたらActiveStorageでハマった件(追記あり)

最近は担当製品の関連アプリを改修しているのですが、Railsのバージョンを5.2系から6.0にアップグレードしました。そのときにActiveStorageでハマったので備忘録を残しておきます。なお、担当製品は既にRails6にしてあります。ただし、まだActiveStorage使ってない。その時の記事はこちら。

patorash.hatenablog.com

普通にRails6へのアップグレード

先ほどの記事を読みながらどういう対応をしたかを思い出して対応。一通り、手動で動かしてみたところ、問題なさそう。そして、RSpecを実行するとめちゃくちゃ落ちる!!びっくりするくらい落ちる!!

テストが落ちる原因を探る

テストが落ちた原因は、まぁRailsのバージョンを上げたのだから、バージョンの差異が原因でしょう。ActiveStorageが絡んでいるところが落ちていたので、調査しました。Qiitaに変更点がまとめられている記事がありました。ありがたい。

qiita.com

ここの中で「3. アップロードしたファイルがストレージに保存されるタイミングがsave時に変更」という見出しが…。

Rails5.2までは、インスタンスのattributeに代入した時点で、アップロードしたファイルがストレージに保存されていました。 Rails6.0では、saveしたタイミングでストレージに保存されるよう変更になっています。saveトランザクションがコミットされた後に、ストレージに保存されます。

おぉ…、まさにこれが原因でした…。

なぜsave後にアップロードでテストが落ちるのか?

理由は、バリデーションのためにActiveStorageにアップロードしたファイルの中身を参照して検証していたからです。

class Message < ApplicationRecord
  has_many_attached :files

  validates :files, presence: true
  validate :check_files, if: -> { self.files.attached? }

  private

  def valid_files?
    self.files.all? do |file|
      file.blob.open do |f| # => Rails6だと、ここでopenできずに落ちる!
        # 検証する
      end
    end
  end

  def check_files
    self.errors.add(:files, :invalid) unless valid_files?
  end
end

ActiveStorage::Blobモデルが保存されていないため、ActiveStorage::FileNotFoundErrorで落ちてました。

アップロード前にファイルの内容を検証できるのか?

blobをopenしても落ちるので、何かいい方法はないものかと調べましたら、Railsのissueに有力な情報がありました。

github.com

attachment_changes['files'].attachablesを使え、とあります。(has_many_attachedならattachablesで、has_one_attachedならattachableになる)

pryを使いながら検証してみたところ、attachableの中身は、{ io: Fileオブジェクト, filename: 'ファイル名' }というHashでした。 そこで、以下のように修正。

def valid_files?
  self.attachment_changes['files'].attachables.pluck(:io).all? do |file|
    # 検証する
  end
end

これでRSpecを実行したところ…。modelのテストは通りました!修正成功です!

SystemSpecが落ちる…

しかし、テストを全部流してみたらSystemSpecが落ちました。手動で画面を操作してみたところ、確かに落ちます。しかも、先ほどの変更したところで…。 self.attachment_changes['files'].attachablesの中身がHashの配列ではなく、Stringの配列になっていました。なんでやねん…。

そもそも最初は手動テストで通ってのに、RSpecのテストが落ちるから修正したら、手動テストが通らなくなったので、もしかしたら画面からの場合はモデルの保存前でもActiveStorageにファイルがアップロードされてる?と考えてpryを仕込みながら調査したところ、ビンゴ。

2020-11-21 追記

もしかしたら画面からの場合はモデルの保存前でもActiveStorageにファイルがアップロードされてる?

これは、ActiveStorageのダイレクトアップロードしているからでした…。それなら当然ですね…。

画面からファイルをアップロードした場合

Messageモデルを保存していない状態で、blobオブジェクトの中身を見た結果。idがあるので、保存済みの証拠です。つまり、ActiveStorageにアプロード済み。(2020-11-21 追記:ダイレクトアップロードしていたからです!)

[1] pry(#<Message>)> self.files.first.blob
=> #<ActiveStorage::Blob:0x00007fb56b9479b0
 id: 320,
 key: "************************",
 filename: "test.csv",
 content_type: "text/csv",
 metadata: {},
 byte_size: 1452,
 checksum: "**************************",
 created_at: Fri, 20 Nov 2020 13:45:17 JST +09:00>
RSpecでファイルをattachした場合

テストでは、attachメソッドで付けてます。

message = FactoryBot.build(:message)
file = File.open(Rails.root.join('spec', 'files', 'test.csv'), 'r')
message.files.attach(io: file, filename: File.basename(file))

pryで中身を見た結果は、以下の通り。idnilのため、ActiveStorageにはまだアップロードされていません。

[1] pry(#<Message>)> self.files.first.blob
=> #<ActiveStorage::Blob:0x00007fdcfd305d68
 id: nil,
 key: nil,
 filename: "test.csv",
 content_type: "text/csv",
 metadata: {"identified"=>true},
 byte_size: 600,
 checksum: "**************************",
 created_at: nil>

導かれる仮説は…

2020-11-21 追記

ダイレクトアップロードしていたからだったので、この仮説は間違っていました。すみません。

  • 画面を経由せずにActiveStorageにアップロードする場合は、モデルの保存が成功したタイミングでアップロード。
  • 画面を経由してActiveStorageにアップロードする場合は、モデルの保存をする以前にアップロード。(これが間違い。ダイレクトアップロードしてたから)

となると、画面を経由しているケースを想定した処理と、経由していないケースを想定した処理を書かないといけません…。ここにすごく違和感を感じる…。

修正

とりあえず、コードは以下のようになりました。

class Message < ApplicationRecord
  has_many_attached :files

  validates :files, presence: true
  validate :check_files, if: -> { self.files.attached? }

  private

  def valid_files?
    if uploaded? # => アップロード時は今まで同様の処理
      self.files.all? do |file|
        file.blob.open do |f|
          # 検証する
        end
      end
    else
      # テストにてattachで添付時はこちら
      self.attachment_changes['files'].attachables.pluck(:io).all? do |file|
        # 検証する
      end
    end
  end

  def check_files
    self.errors.add(:files, :invalid) unless valid_files?
  end

  def uploaded?
    self.files.all? { |file| file.blob.persisted? }
  end
end

これでテストは全て通るようになりました。やった!!!

不満点

違和感を感じると書いていますが、その違和感の正体は、「これだとモデルのユニットテストでActiveStorageにファイルが保存されているケースを検証できない」という点です。attachだと保存されないから…。画面からのアップロードでも、ActiveStorageのアップロード前の状態で、デフォルトで一時ファイルへのパス情報をどっかに保存していたらいいのに…。ダイレクトアップロードでなければ一時ファイルに保存されていることを確認したので、私が間違えていました。

何か他にいい方法をご存知の方がいらっしゃいましたらコメントでもツイッターでもいいので教えてください!!

2020-11-21 追記

画面からアップロードした際に先にクラウドにアップロードされていた原因はダイレクトアップロードをしていたためでした。先にコード読めよって話ですけど、自分が作ったところじゃなかったのでテストが落ちる原因にばかり考えが巡ってしまってActiveStorageのダイレクトアップロードの存在を忘れてました。このブログを書いた後に、急にピーン!ときました。

ダイレクトアップロードではない場合、当然ながらモデルが保存されるまではアップロードされず、一時ファイルに置かれていました。attachableの内容もHashではなく、ActionDispatch::Http::UploadedFileになっていたので、これに合わせて修正すればよさそうです。

>> self.attachment_changes['files'].attachables.first
=> #<ActionDispatch::Http::UploadedFile:0x00007f9d4a54ef18
 @tempfile=#<Tempfile:/var/folders/7m/9fg43zzx58lb4715wm1dkj00yj5swh/T/RackMultipart20201121-93972-xgi34u.csv>,
 @original_filename="test.csv",
 @content_type="text/csv",
 @headers="Content-Disposition: form-data; name=\"message[files][]\"; filename=\"test.csv\"\r\nContent-Type: text/csv\r\n">

となると………

  • ダイレクトアップロードの場合のバリデーション
  • ダイレクトアップロードじゃない場合のバリデーション
  • attachメソッドを使った場合のバリデーション

の3パターンを考えないといけないわけか…。うっ、頭が…。

(これはあくまでファイルの中身をチェックして保存したい場合の話なので、普通に画像をアップロードするだけ、とかだとこんな面倒な話にはなりませんのでActiveStorageが怖いわけではありません)

2020-11-26 追記

ダイレクトアップロードじゃないケースは実装上、ないので、そこはテストで放置して、一応attachメソッドも考慮したテストを追加して対応しました。 FactoryBotのコードは、こう。

FactoryBot.define do
  factory :message do
    sequence(:content) { |n| "メッセージ#{n}" }
  end

  trait :with_direct_upload_file do
    after :build do |message|
      valid_file = File.open(Rails.root.join('spec', 'files', 'valid_file.csv'), 'r')
      blob = ActiveStorage::Blob.create_and_upload!(
          io: valid_file,
          filename: File.basename(valid_file),
          identify: false
      )
      message.files.build(blob: blob)
    end
  end

  trait :with_attach_file do
    after :build do |message|
      valid_file = File.open(Rails.root.join('spec', 'files', 'valid_file.csv'), 'r')
      message.files.attach(io: valid_file, filename: File.basename(valid_file))
    end
  end
end

そして、RSpecはこう。

RSpec.describe Message, type: :model do
  context 'ダイレクトアップロードの場合' do
    subject { FactoryBot.build(:message, :with_direct_upload_file) }
    it '保存できること' do
      # 検証する
    end
  end

  context 'attachの場合' do
    subject { FactoryBot.build(:message, :with_attach_file) }
    it '保存できること' do
      # 検証する
    end
  end
end

とりあえず、これでモデルのテストでもダイレクトアップロードを考慮したテストを行うことができました。

デジタル・ミニマリストを読んだ

丸善CHIホールディングス株主優待を使って買った本。岡山駅丸善書店があったのでよかった。この本を選んだ理由は、なかなかスマホから離れられないから…。デジタル・ミニマリストとはどういうものなのか知りたかった。この本の著者はコンピュータ科学の専門家で、単にミニマリストって言ってる人ではなさそうだったのも買った決め手。

アテンション・エコノミーを知る

人々がフェイスブックツイッターなどのSNSやニュースアプリに費やしている時間はかなりのものとなる。アテンション・エコノミーとは、日本語にすると注意経済なのだが、つまりは人々の注意を引けば引くほど儲かる仕組みということだ。PVが増えれば増えるほど、広告のビュー数が増えるので儲かる企業。向こうは注意を引けば儲かるので、多くの予算と労力をかけてこちらの時間を奪おうとしてくる。こちらの時間をそのアプリが奪えば奪うほど儲かる仕組みだからで、それに対抗するのは非常に難しい…と書いてあった。

スマホアテンション・エコノミーの最強の味方

要約すると「スマホはヤバい。便利だけど恐ろしい。なぜならばアテンション・エコノミーにとっての最強の集金マシンだからだ。あなたの注意を引くためなら何でもやってくる。あなたの時間は彼らのお金。」という感じだと思う。たしかにその通り…。ちょっと暇になるとついついスマホを見てしまい、気付いたらすぐに5分くらい経ってしまう。それの繰り返しで大量の時間をお金に替えられている。

別にお金に替えられるのが悔しいから止めようっていうふうに言ってるんじゃなくて、相手はお金に替えるのが目的だから、狡猾にあなたの時間を奪いに来るんだよっていうことを言ってる。

どう対処するか?

まずは1ヶ月、スマホからSNSを削除してみようという提案をしていた。SNSに限らず、一気に見てしまいがちなNetflixなどの動画サービスや隙間時間にやってしまいがちなスマホゲームなども含まれる。ここで重要なのは、退会は別にしない。いざとなればPCから見られるようにしておく。これだけでも、最初はスマホ依存症の禁断症状でついつい開こうとするけれど、アプリがないので何もできないで、他の事をやるようになり、だんだん有意義に過ごせるという。

戻すときは全部戻すのではなく、生活してみて本当に重要だったものとそうでないものに分けて、節度を持って再度取り入れる。不要なものはそのままさようなら。

有意義な時間の過ごし方について

スマホ依存から脱却したとして、暇な時間があるとまた逆戻りしかねないからか、有意義な時間の過ごし方についての提案が色々とあった。例えば

  • 趣味を持つ(できればデジタルでないもの)
  • コミュニティの活動に参加する
  • 手仕事を身につける

手仕事を身につけるところで面白かったのは、どうやって手仕事を学ぶのか?というのでYouTubeの動画を見たらいっぱい紹介されてるっていうふうに書いてあったところだ。つまりはデジタルであっても有意義な活動のために取り入れるならOKってことだ。たしかに魚の捌き方の動画とかもあったりするので、そういうのは面白そう。

代替手段について

ニュースアプリの代替は新聞だったり、スマホの代替はフィーチャーフォンだったりと、そういうのを薦めてる。スローメディアを活用しようということだった。スローメディアという言い方だと遅いみたいな感じに聞こえるが、ここのスローはスローライフみたいな部類のやつである。速報性ばかりを気にすると、目につけたいから過激なタイトルを付けたり速報性を重視しすぎて間違った情報を流したりすることもあり、速ければいいかというとそうでもない。スローメディアはファクトチェックをして情報を整理してから出してくるので、たくさんの速報性メディアを読むよりも短い時間でより深く物事を理解できることが多い。と書かれていた。

フィーチャーフォンでも最近はさほど困らない。なぜならPCも軽量化されてきていて、持ち運びにはさほど苦労しないので調べものがしたくなったらPC使えばいいから、だそうだ。うーん、まぁそれはそうかもしれんけれど…と思うけれど、それだけでアテンション・エコノミーから距離を置くことができるという点ではいいのかも。

全体的な感想

この本を読んでてめちゃめちゃ刺さった言葉があって、それは「孤独欠乏症」だった。いつでもSNSで繋がっていてメッセージを送りあえるせいで、本当の意味で孤独になれる時間が取れない。孤独は内省を促したり、アイデアをまとめたりするのに必要な時間なのにそれが十分に取れていないのが現代人である、というのは本当にそうだなと思った。昔はもっと色々アイデアをまとめたりしていたのに、最近は全然できていなかったので、完全に毒されていたなぁと思う。

この本を読んでいる間に、アプリは削除していないけれど、なるべくスマホに近寄らないようにしてみたが、それだけでだいぶ本を読んだり子供とちゃんと遊ぶ時間が増えたので既に効果を実感している。

個人的な取り組み

SNSアプリやニュースアプリもそうだが、個人的にヤバいのは投資系アプリで、ついつい頻繁に相場のチェックをしてしまう。損してないか?が気になってしまうのだろう。まぁ大損してるんですけど。 大損してるにも関わらず更に時間まで奪われてしまって、本当に本末転倒だな…と大いに反省したので、FX用の資金を引き揚げて株のほうに回した。これだけでとりあえず円相場を見なくて済む…。そして、株相場も頻繁に見てしまうので、株主優待がいい企業と応援している企業だけに絞って投資しようと思っている。優待のためなので、相場の上げ下げに一喜一憂しなくても済む。これで週に一回くらいのチェックで済むし、見る回数自体を減らしてもいいので、スマホから投資系のアプリを消せる。

SNSも見る回数がだいぶ減っている。特にFacebook見てない。時々近況報告を書きこむ程度でいいかもしれない。twitterは仕事中には時々見る程度だが、やはり減ってる。LINEの通知がウザいのでなんとなく入れた企業アカウントはバッサバッサと消してる。通知もサイレント通知に替えた。

FreedomというWebサイトやアプリをブロックする用アプリがあるらしいので、それのアカウントを作っておきたい。

SNSスマホとの付き合い方を見直せる本

めっちゃいい本だった。この本は別にSNSを否定していない。使われるのではなく、効果的に使うようになろう!そう言っている。 自分自身、これから取り組んでいくところなんだけれど、それでも随分有意義に時間を過ごせるようになった感じがする。そして、なんとなく時間が緩やかに過ぎるようになった気もしている…。内省する時間が増えたからだろうか…。孤独欠乏症から少し立ち直れそう。

RSpecによるRailsテスト入門を読んだ

RSpecの本はすごく前にThe RSpec Bookを読んでいたけれど、もうすでにだいぶ古いし、他の人に薦められるかってのと自分が知らないことが書いてあるかもってことで、Everyday Rails RSpecによるRailsテスト入門を買って読んでみた。

leanpub.com

とはいえ、買っていたのは何時だっただろうか…。半年前くらいだっただろうか…。積読の解消がなかなか終わりません。EBookだと尚更。

テストの書き方だけでなく、テストを書くための指針が分かる

慣れたもんだと何をテストすればいいかは自ずと分かっているのである程度雰囲気でガシガシ書いていけるのだけれど、慣れていないとどこまでテストを書くべきなのか、とか、どこからテストを書くべきなのか、がわからないかと思う。まぁRailsの場合は普通はModelから書いていくと思うが、そのあたりも押さえてある。そして、ControllerSpecとRequestSpecについてや、FeatureSpecについて、付録ではSystemSpecについても書いてあった。Viewのテストはしないってのも、現実的。まぁしないというよりはFeatureSpecでやるって話だけれども。

TDDにも踏み込んでいて、そのときはFeatureSpecから書いていってて、それもまたいいと思った。写経すればTDDを体験できる。

そして、モックとスタブの話もあってよかった。私は未だに何気に混乱する…。モックとスタブって、時々しか書かないからっていうのはあるけれど。そして、モックとスタブを使うときの指針についても書いてあった。概ね同意だけれど、まぁActiveRecordの振舞をスタブ化することはあるかなぁと思う。

FactoryBotの解説がよかった

FactoryBotでデータを準備するときの書き方が色々解説されていてよかった。traitとか、こういうのは本当に使いたいときに調べない限りなかなか目に付かないので、先に教えられると嬉しい情報だと思う。RequestSpecで使えるFactoryBot.attributes_forは多分使ったことがなかったと思うので、今後使っていきたい。

SystemSpecにしていこうと思った

うちのプロジェクトはRails 6.0系だけれど、まだFeatureSpecを使っているので、付録のSystemSpecへの移行を見ながらやっていこうと思った。こういう記事は何気に嬉しい。

感想

後輩の指導にも使えそうだし、自分にも学びがあってよかった。正直Railsをやってると、実装よりもテストを書くほうが時間がかかるときがあったりするので、テストを効率的に書けるようになるのは非常に重要だと思っている。しかしRSpecについて学ぼうとすると、この本かThe RSpec Bookだと思うのだけれど、The RSpec Bookは古すぎるので、この本を自信を持ってお薦めしていきたい。

テストのリファクタリングやっていこうかなぁ~という気持ちになった。

leanpub.com

has_manyの最新のデータをhas_oneで関連付けする方法

元ネタはこのQiitaの投稿。

qiita.com

この投稿のように、UserモデルとArticleモデルが1対多になっていて、ユーザーに紐づいた最新の記事を取得したいこととかはあると思います。私がやってるプロジェクトでも似たようなことがありました。ユーザーに紐づいたデータがバージョン管理されていて、最新のが欲しいとき、とか…。

N+1が起きるコード

以下のようなModelがあったとします。

class User < ApplicationRecord
  has_many :articles, dependent: :destroy
end
class Article < ApplicationRecord
  belongs_to :user
end

これで最新の記事を取るためにメソッドを定義したら、こうなります。

class User < ApplicationRecord
  has_many :articles, dependent: :destroy

  def latest_article
    articles.order(id: :desc).first
  end
end

これをViewでループすると、残念なことにN+1問題が発生します。

<ul>
<% @users.each do |user| %>
  <li><%= link_to user.latest_article.title, user.latest_article %></li>
<% end %>
</ul>

Userが3名いて、記事が登録済みの場合、このようなログが出ました。

Started GET "/users/" for 127.0.0.1 at 2020-10-31 02:58:25 +0900
Processing by UsersController#index as HTML
  Rendering users/index.html.erb within layouts/application
  User Load (1.2ms)  SELECT "users".* FROM "users"
  â³ app/views/users/index.html.erb:6
  Article Load (0.9ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ? ORDER BY "articles"."id" DESC LIMIT ?  [["user_id", 1], ["LIMIT", 1]]
  â³ app/models/user.rb:7:in `latest_article'
  CACHE Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ? ORDER BY "articles"."id" DESC LIMIT ?  [["user_id", 1], ["LIMIT", 1]]
  â³ app/models/user.rb:7:in `latest_article'
  Article Load (0.1ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ? ORDER BY "articles"."id" DESC LIMIT ?  [["user_id", 2], ["LIMIT", 1]]
  â³ app/models/user.rb:7:in `latest_article'
  CACHE Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ? ORDER BY "articles"."id" DESC LIMIT ?  [["user_id", 2], ["LIMIT", 1]]
  â³ app/models/user.rb:7:in `latest_article'
  Article Load (0.2ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ? ORDER BY "articles"."id" DESC LIMIT ?  [["user_id", 3], ["LIMIT", 1]]
  â³ app/models/user.rb:7:in `latest_article'
  CACHE Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ? ORDER BY "articles"."id" DESC LIMIT ?  [["user_id", 3], ["LIMIT", 1]]
  â³ app/models/user.rb:7:in `latest_article'
  Rendered users/index.html.erb within layouts/application (Duration: 87.8ms | Allocations: 17016)

ここではNが3のため、3+1=4回のクエリが発行されていることが確認できました。

latest_articleをhas_oneで定義

では、見出しの通りにhas_oneにしていきます。

class User < ApplicationRecord
  has_many :articles, dependent: :destroy
  has_one :latest_article,
           -> { where(id: Article.group(:user_id).select('MAX(id)')) },
           class_name: 'Article'
end

肝は、whereのサブクエリで外部キーであるuser_idでGROUP BYを行い、その最新の記事IDを取るためにMAX(id)をしているところです。これで、取得されるのはユーザー毎の最新の記事のみになります。

こうなると、メソッドではなくリレーションになったため、preloadなどが使えるようになります。

使ってみる

では、Controllerでpreloadを使ってみます。

class UsersController < ApplicationController
  def index
    @users = User.all.preload(:latest_article)
  end
end

Viewは変わりません。

<ul>
<% @users.each do |user| %>
  <li><%= link_to user.latest_article.title, user.latest_article %></li>
<% end %>
</ul>

しかし、発行されるSQLは2回になっています!

Started GET "/users/" for 127.0.0.1 at 2020-10-31 03:06:53 +0900
Processing by UsersController#index as HTML
  Rendering users/index.html.erb within layouts/application
  User Load (0.6ms)  SELECT "users".* FROM "users"
  â³ app/views/users/index.html.erb:6
  Article Load (2.2ms)  SELECT "articles".* FROM "articles" WHERE "articles"."id" IN (SELECT max(id) FROM "articles" GROUP BY "articles"."user_id") AND "articles"."user_id" IN (?, ?, ?)  [["user_id", 1], ["user_id", 2], ["user_id", 3]]
  â³ app/views/users/index.html.erb:6
  Rendered users/index.html.erb within layouts/application (Duration: 56.3ms | Allocations: 11242)

eager_loadを使えば1回でも済みますが、どういうクエリが発行されているかを確認しやすいのでpreloadにしてみました。

has_oneがこういうふうに使えるのは便利です!