graphql-ruby 関連の CustomCop を作ってみる
この記事は 🎅GMOペパボエンジニア Advent Calendar 2023 - Adventar 11日目の記事です🎅🎄
私が所属している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のコードを参考にすれば実現できそうです。今後も気軽に作ってみようと思います。