Madogiwa Blog

主に技術系の学習メモに使っていきます。

RuboCopに自分で定義した新しいルールを追加する方法のMEMO🤖

RuboCopは、Rubocop::Cop::Copを継承することで独自のルールを追加することできるようで、 レビューでよくコメントもらう内容等、RuboCopで検知できるようになったら便利だなと思い、 ルールを作成してみたので、そのへんの手順等をMEMOしておきます✍

公式の開発用のDocはこちら📝

docs.rubocop.org

今回作ったもの

今回は下記のようなことを検証するルールを作ってみました🤖

SQLの実行回数削減のため、pluckを使っていたらselectを使うことをサジェストする

# bad
Foo.where(id: Bar.pluck(:foo_id))
# good
Foo.where(id: Bar.select(:foo_id))

where内でLIKEを使っていたらsanitized_sql_likeを使うことをサジェストする

# bad
Foo.where('title LIKE ?', "%#{title}%")
# good
Foo.where('title LIKE ?', "%#{sanitized_sql_like(title)}%")

作ったものは下記のリポジトリにアップしています。

github.com

新しいルールを作るのに必要なこと

新しいルール作ってrubocop実行時に静的解析を行うようにするには下記が必要なようです👀

  • RuboCop::Cop::Copを継承したClassを作る
  • RuboCop::Cop::Copを継承したClassのテストを書く
  • 作成したClassを.rubocop.ymlrequireする

RuboCop::Cop::Copを継承したClassを作る

今回、私が実際に作ったClassは下記のような形です。

下記は、SQLの実行回数削減のため、pluckを使っていたらselectを使うことをサジェストするルールです。

require 'rubocop'

module CustomCops
  # @example
  #   # bad
  #   Foo.where(id: Bar.pluck(:foo_id))
  #
  #   # good
  #   Foo.where(id: Bar.select(:foo_id))
  class UseSelectInWhere < RuboCop::Cop::Cop
    MSG = 'Use select instead of pluck in where to use subqueries to reduce SQL executions.'

    def_node_matcher :where_in_pluck_candidate?, <<~PATTERN
      (send _ :where (:hash (:pair (:sym _) (send _ :pluck (:sym _)) ...)))
    PATTERN

    def on_send(node)
      where_in_pluck_candidate?(node) do
        add_offense(node)
      end
    end

    def autocorrect(node)
      # TODO
    end
  end
end

ちょっと解説すると

  • MSGに設定された値が違反時にメッセージとして表示されるようです。
  • def_node_matcherwhere{ sym: 任意のオブジェクト.pluck}のような形で引数に渡しているか解析するmather where_in_pluck_candidate?を定義しています。※このmatherは抜け漏れがあるかも。。。
  • where_in_pluck_candidate?に合致するようなものがあったらadd_offenseで警告を出しています。
  • 今回はTODOにしてしまっていますが、autocorrectに処理を書くとautocorrectの処理を定義出来ます。

RuboCop::Cop::Copを継承したClassのテストを書く

先程作ったClassのテストは下記のような形で行うようにしました。

RuboCop::RSpec::ExpectOffenseをincludeしてexpect_offenseに、コードとメッセージを渡してあげるような形でテストするようです👀

expect_offense内の記載内容をいい感じに取得する方法がわからなかったので、とりあえずテストを実行して失敗したときの値が、それっぽかったらコピペするような形にしたが、もっといい方法がありそう。。。

require 'rubocop'
require 'rubocop/rspec/support'

RSpec.configure do |config|
  config.include(RuboCop::RSpec::ExpectOffense)
end

describe CustomCops::UseSelectInWhere do
  subject(:cop) { described_class.new }

  it 'raised offense when use `pluck` in `where`.' do
    expect_offense(<<-RUBY)
      Foo.where(id: Bar.pluck(:foo_id))
      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use select instead of pluck in where to use subqueries to reduce SQL executions.
    RUBY
  end
end

作成したClassを.rubocop.ymlrequireする

.rubocop.ymlで先程作ったclassのファイルをrequireすることでルールを追加します。

require: './cops/use_select_in_where'

rubocop実行時に静的解析されるようになりました🙌

$ be rubocop sample/
sample/use_select_in_where.rb:4:23: C: CustomCops/UseSelectInWhere: Use select instead of pluck in where to use subqueries to reduce SQL executions.
  scope :recent, -> { Foo.where(id: Bar.pluck(:foo_id)) }
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

おわりに

レビューでよくコメントが入る内容や、プロジェクト独自で拡張したクラスを使う必要がある場合等、rubocopの独自ルールとして定義しておくと、レビュー時の抜け漏れ等が防げて良さそうですね✨

追記: RuboCop Version 1.0で作り方が変わるかも

Version 1.0に向けてCop周りの仕様の見直しが結構入っているようなので、これから作るときは下記を参考にしたほうが良さそう👀

rubocop/v1_upgrade_notes.adoc at master · rubocop-hq/rubocop · GitHub

参考

sinsoku.hatenablog.com

qiita.com

koic.hatenablog.com