💨

before_action, skip_before_action を強制する Custom Cop をつくる

に公開

特定の before_action / skip_before_action の記述を強制する Custom Cop を作りたくなったため、作成の過程で検討したことと、実施したことを記録する。

on_class を使う

着手当初、on_send で作成しようとしたが、before_action には only オプションがあるため、controller class に対して定義されているメソッドを知る必要があると考えた。

そのため、クラスで定義、利用されているメソッドの情報を Node から取得できる on_class を利用することとした。

尚、controller なのかどうかを確認するためのメソッドについては、rubocop-railsのRails/ActionControllerFlashBeforeRender にある action_controller? を参考にするとよい。

Controllerクラス上で定義されている public メソッドを抽出する

コントローラに定義されているメソッドは、Rails/ActionOrder の action_declarations が参考になる。

メソッド名は上記のリンクを参考に取得できるが、そのメソッドが public かどうかは RuboCop::Cop::DefNode の node_visibility や、RuboCop::Cop::DefNode の non_public? で抽出できる。

    def_node_search :action_declarations, '(def {%1} ...)'

    def public_method_names(node)
      action_declarations(node, actions).filter_map do |current|
        next if node_visibility(current) != :public || non_public?(current)

        current.method_name
      end
    end

    def actions
      %i[index show create update destroy]
      # .rubocop.yml でフィルタするメソッド名を管理してもよい
    end

ここで抽出した public メソッドが、before_action や skip_before_action の only オプション に渡される。

before_action/skip_before_action の only に指定されているメソッドを抽出する

ここでは対象のメソッドを filter_action! というメソッドを対象として記述する。

before_actiononly つけたりつけなかったりができるため、この両方に対応する必要がある。only あり/なしを $ をつかった上で一気に書ける方法がよくわからなかったため、別々に定義した。

    def_node_search :filter_action, <<~PATTERN
      (send nil? :before_action (sym :filter_action!) (hash <(pair (sym :only) $_ ...)>))
    PATTERN

    def_node_search :filter_action_no_args?, <<~PATTERN
      (send nil? :before_action (sym :filter_action!))
    PATTERN

key である only に対応する value を $_ として受けて後で使えるようにしている。

また、super class や module で before_action を使っている場合に skip_before_action を使われる可能性があるため、これらの def_node_search を定義する。

    def_node_search :skip_filter_action, <<~PATTERN
      (send nil? :skip_before_action (sym :filter_action!) (hash <(pair (sym :only) $_ ...)>))
    PATTERN

    def_node_search :skip_filter_action_no_args?, <<~PATTERN
      (send nil? :skip_before_action (sym :filter_action!))
    PATTERN

$_ には only: valuevalue の部分が適用される。単体のシンボルになることも Array になることもあるので両方に対応するよう、以下のようにした。

    def filter_action_method_names(node)
      filter_action(node).flat_map do |arg|
        if arg.array_type?
          arg.values.map(&:value)
        elsif arg.sym_type?
          arg.value.to_sym
        end
      end
    end

    def skipped_filter_action_method_names(node)
      skip_filter_action(node).flat_map do |arg|
        if arg.array_type?
          arg.values.map(&:value)
        elsif arg.sym_type?
          arg.value.to_sym
        end
      end
    end

強制するロジックをつくる

警告のメッセージは簡易に書いているが、以下のようになった。

before_action と skip_before_action の only なしが共存しないこと、同一メソッドが両方の only の対象にならないこと、before_action 内 / skip_before_action 内で同一メソッドの指定が重複していないこと、only の対象となっていないメソッドがないこと、をチェックしている。


    def on_class(node)
      return unless action_controller?(node)

      public_method_names = public_method_names(node)
      filter_action_target = filter_action_method_names(node)
      skipped_target = skipped_filter_action_method_names(node)

      # public メソッドなければ終了する
      return if public_method_names.blank?

      # before_action :filter_action! と skip_before_action :filter_action! の引数なし両方の指定がある
      if filter_action_no_args?(node) && skip_filter_action_no_args?(node)
        add_offense(node, message: '重複指定エラー')
        return
      end

      # before_action :filter_action! 引数なしは全指定なのに skip_before_action :filter_action! にメソッド指定がある
      if filter_action_no_args?(node) && skipped_target.present?
        add_offense(node, message: '重複指定エラー')
        return
      end

      # skip_before_action :filter_action! 引数なしは全指定なのに before_action :filter_action! にメソッド指定がある
      if skip_filter_action_no_args?(node) && filter_action_target.present?
        add_offense(node, message: '重複指定エラー')
        return
      end

      # only にわたすメソッド に重複がある
      duplicated_methods = filter_action_target & skipped_target
      if duplicated_methods.present?
        add_offense(node, message: '重複指定エラー')
        return
      end

      # only にわたしたメソッドに重複がある
      dup_in_before_action = filter_action_target.select { |m| filter_action_target.count(m) > 1 }
      dup_in_skip_before_action = skipping_target.select { |m| skipping_target.count(m) > 1 }
      dup_methods = (dup_in_before_action + dup_in_skip_before_action).uniq
      if dup_methods.present?
        add_offense(node, message: '重複指定エラー')
        return
      end

      public_method_names.each do |method_name|
        next if filter_action_target.include?(method_name) || skipped_target.include?(method_name)

        add_offense(node, message: '指定漏れエラー')
      end
    end

before_action 内 / skip_before_action 内で同一メソッドの指定が重複していないこと、については、別の Custom Cop に切り出したほうが正しそうだが、ここでは含めるようにした。

まとめ

before_action / skip_before_action を強制する Custom Cop をつくる際に検討、記述したことをまとめた。

以下の点に注意して実装した。

  • public メソッドを抽出して、それらに対し指定漏れがないようにする
  • only 指定あり/指定なし で適用範囲が変わることを考慮にいれる
  • メソッド指定の重複が before_action / skip_before_action で重複しないようにする
  • only 内で対象メソッドの重複がないようにする
  • 対象にするメソッドの指定漏れがないようにする

運用していく中で考慮漏れに露呈する可能性はあるが、その場合は追加で対処する。

あしたのチーム Tech Blog

Discussion