やなぎにっき

学んだことの記録

graphql-ruby 関連の CustomCop を作ってみる

この記事は 🎅GMOペパボエンジニア Advent Calendar 2023 - Adventar 11日目の記事です🎅🎄

adventar.org

私が所属しているminneのプロダクトではGraphQLを採用しています。

Ruby on Rails で開発するにあたり、以下のGemを使用しています。

普段開発している中で rubocop-graphql にあるcopだけでなく、「こういうcopがあればいいな」と思うことがしばしばあるので作ってみます。

作りたい CustomCop

作品情報のProductモデルと購入情報Saleモデル があるとします。ProductとSaleは一対一の関係です。

一時期、既存のコードで以下の様なクエリで呼び出せるスキーマのようになっていたことがありました。(例なので実際のクエリとは異なります)

query ($id: String!) {
  sale(id:$id) {
    id
    product_id
  }
}

このようにsaleに関連づくproductの情報が product_id field のみの場合、クライアントはsaleに関連づくproductのデータを取得する際はproduct_id を元にQuery productから再度クエリ必要が出てきてしまいます。それでは一度で求めたい情報を取得できるというGraphQLのメリットが活かせません。そのため、ProductType の様なオブジェクトを取得できる product fieldが取得できるとよさそうです。

そのため、nodeのfield名が xxx_id のようにidを末尾で終わる場合にwarningを出すcopを作りたいと思います。

module Types
  class SaleType < Types::BaseObject
    field :id, ID, null: false
    field :quantity, Int, null: true
    field :total_amount, Float, null: true
    field :product_id, String, null: true # ここで warningを出したい
  end
end

warningを出したい条件

  • fieldで始まる第一引数の名前
  • かつその名前が_idで終わる

CustomCop を作ってみる

実装は Development :: RuboCop Docs や記事を参考にしました。

rubocopはパーサーライブラリを使用して、抽象構文ツリー(AST)を取得しています。

parser gemで取得対象にしたいコードのASTを取得してみます。

$ gem install parser
❯ ruby-parse -e 'field :product_id, String, null: false'
(send nil :field
  (sym :product_id)
  (const nil :String)
  (kwargs
    (pair
      (sym :null)
      (false))))

今回はfieldから始まる行の、シンボル名を検知したいため、検知したいコード 'field :product_id' をparseしてみます

❯ ruby-parse -e 'field :product_id'             
(send nil :field
  (sym :product_id))
  • send ノードはメソッドの呼び出しを表す。nilはレシーバーがnilであり、send nil :field はレシーバーのないfieldメソッドを表している
  • fieldメソッドの引数として、 product_id というシンボルが渡されている

このASTを元に、CustomCopを作ってみます。

matcher

field :field_name のパターンにマッチし、field_name だけを取得するmatcherを書いてみます。

def_node_matcher :field_name, <<~PATTERN 
  (send nil? :field (:sym $_) ...)
PATTERN
  • nilのレシーバーからfieldのメソッドが呼ばれた時
  • 引数に指定されたシンボルを受け取る

メソッド

def on_send(node)
  field_name = field_name(node).to_s

  return unless field_name.end_with?("_id")

  add_offense(node)
end
  • nodeからfield_nameを抽出する
  • 末尾が _id であれば add_offenseを呼び出し、警告を発生させる

最終的なコード

最終的なCopのコードとテストです。 自動修正とテストも書いてみました。

# frozen_string_literal: true

module RuboCop
  module Cop
    module GraphQL
      #  This cop checks whether field names are ending with _id.
      #
      # @example
      #   # good
      #
      #   class UserType < BaseType
      #     field :profile, ProfileType, null: true
      #   end
      #
      #   # bad
      #
      #   class UserType < BaseType
      #     field :profile_id, String, null: true
      #   end
      #
      class FieldNameWithId < Base
        extend AutoCorrector

        MSG = "Field names should not end with _id."

        def_node_matcher :field_name, <<~PATTERN
          (send nil? :field (:sym $_) ...)
        PATTERN

        def on_send(node)
          field_name = field_name(node).to_s

          return unless field_name.end_with?("_id")

          add_offense(node) do |corrector|
            rename_field_name(corrector, field_name, node)
          end
        end

        private

        def rename_field_name(corrector, field_name, node)
          name_field = field_name
          new_line = node.source.sub(name_field, name_field.gsub(/_id$/, ''))
          corrector.replace(node, new_line)
        end
      end
    end
  end
end
# frozen_string_literal: true

RSpec.describe RuboCop::Cop::GraphQL::FieldNameWithId, :config do
  context "when end of field name is not _id" do
    it "not registers an offense" do
      expect_no_offenses(<<~RUBY)
        class UserType < BaseType
          field :product, ProductType, null: true
        end
      RUBY
    end
  end

  context "when end of field name is _id" do
    it "registers offense" do
      expect_offense(<<~RUBY)
        class UserType < BaseType
          field :profile_id, String, null: true
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Field names should not end with _id.
        end
      RUBY

      expect_correction(<<~RUBY)
        class UserType < BaseType
          field :profile, String, null: true
        end
      RUBY
    end
  end
end

rubocop-graphql のクラスを使う

と、ここまで手順通りにやってきましたが、rubocop-graphql ではFieldをcopする用のメソッドが定義されているので、わざわざmatcherを定義する必要はなかったのでした。

include RuboCop::GraphQL::NodePattern
def on_send(node)
  return unless field_definition?(node)

  field = RuboCop::GraphQL::Field.new(node)
  return unless field.name.to_s.end_with?("_id")

  add_offense(node) do |corrector|
    rename_field_name(corrector, field, node)
  end
end

感想

CustomCopを作り、アプリケーションに導入すると過去のコードを統一できるだけでなく、今後もレビューでのやりとりが減ること、コード規約が明文化されることなど嬉しい場面が多いことに気づきました。

今までparserに触れてこなかったのですが、いざやってみると思ったよりハードルが低いことにも気づけてよかったです。さらによほど複雑な条件・厳密にチェックしようとしない限り、rubocopのコードを参考にすれば実現できそうです。今後も気軽に作ってみようと思います。

参考

Development :: RuboCop Docs

RuboCopの新しいルールを追加する方法(Custom Copの作り方) - アジャイルSEの憂鬱

RuboCopにカスタムルールを実装する方法について - BOOK☆WALKER inside