
この記事はノバセル テクノ場 出張版2025 Advent Calendar 2025の23日目の記事です。
はじめに
ノバセルでエンジニアをしている原です。
本記事では、マイグレーションファイルで外部キー制約が使われていないかをチェックするRuboCopカスタムCopの実装方法を紹介します。
なぜ作ったのか
今開発しているプロダクトでは外部キー制約をつけないルールにしています(外部キー制約の是非についてはここでは語りません)。
コードレビューの際に気をつけてはいるものの、うっかり見逃してしまうケースがありました。RuboCopでチェックできないかと探してみたのですが、ちょうどいいCopが見つかりませんでした(実はあるかもしれませんが)。
ないなら作ってしまえ、ということで自作しました。
やりたいこと
プロジェクトのルールとして、マイグレーションファイルで以下を禁止したいケースがあります。
add_foreign_keyメソッドの使用referencesやadd_referenceのforeign_key: trueオプション
これをコードレビューで毎回チェックするのは手間なので、RuboCopで自動検出できるようにします。
RuboCop Copの仕組み
実装に入る前に、RuboCopのカスタムCopがどう動くか簡単に説明します。
ASTとノード
RuboCopはRubyのソースコードをAST(抽象構文木)に変換して解析します。例えば以下のコード:
t.references :user, foreign_key: true
は、こんなASTになります:
(send
(lvar :t) :references
(sym :user)
(hash
(pair
(sym :foreign_key)
(true))))
sendはメソッド呼び出しを表すノードタイプです。
on_sendコールバック
on_sendメソッドを定義すると、RuboCopがASTを走査する際に、sendノード(メソッド呼び出し)を見つけるたびに呼び出されます。
def on_send(node) # nodeにはメソッド呼び出しの情報が入っている node.method_name # => :references node.arguments # => 引数のノード配列 end
他にも on_class、on_def、on_hash など、ノードタイプごとにコールバックがあります。
def_node_matcher
RuboCopには def_node_matcher というDSLがあり、ASTのパターンマッチングを簡潔に書けます。
def_node_matcher :add_foreign_key?, '(send nil? :add_foreign_key ...)'
このパターンの意味:
| 部分 | 意味 |
|---|---|
send |
メソッド呼び出しノード |
nil? |
レシーバがない(add_foreign_key :table のような形式) |
:add_foreign_key |
メソッド名が add_foreign_key |
... |
引数は何でもOK |
定義すると add_foreign_key?(node) というメソッドが使えるようになり、マッチすれば truthy、しなければ nil を返します。
これを踏まえて実装を見ていきます。
実装
# frozen_string_literal: true module RuboCop module Cop module Custom class MigrationForeignKey < ::RuboCop::Cop::Base # Node Patternの定義 # nil? はレシーバなし(add_foreign_key :table)、... は引数不問 def_node_matcher :add_foreign_key?, '(send nil? :add_foreign_key ...)' def on_send(node) # .rubocop.yml の Include で対象を絞っているなら # processed_source のチェックは削除してOKです。 if add_foreign_key?(node) add_offense(node, message: 'add_foreign_keyメソッドの使用は禁止されています。') return end check_foreign_key_option(node) end private def check_foreign_key_option(node) # references や add_reference などのメソッドの引数を走査 node.arguments.each do |arg| next unless arg.hash_type? arg.pairs.each do |pair| # key が :foreign_key かつ value が false 以外なら違反 next unless pair.key.sym_type? && pair.key.value == :foreign_key next if pair.value.false_type? add_offense(pair, message: 'foreign_keyオプションはfalseにする必要があります。') end end end end end end end
設定
ファイル配置
lib/rubocop/cop/custom/migration_foreign_key.rb に配置します。
.rubocop.yml
require: - ./lib/rubocop/cop/custom/migration_foreign_key Custom/MigrationForeignKey: Enabled: true Include: - db/migrate/**/*
検出例
以下のようなマイグレーションファイルがあった場合:
class CreateOrders < ActiveRecord::Migration[7.0] def change create_table :orders do |t| t.references :user, foreign_key: true # ← 検出される t.timestamps end add_foreign_key :orders, :shops # ← 検出される end end
rubocop を実行すると警告が出ます。
db/migrate/20241223000000_create_orders.rb:4:29: C: Custom/MigrationForeignKey: foreign_keyオプションはfalseにしてください。
t.references :user, foreign_key: true
^^^^^^^^^^^^^^^^^
db/migrate/20241223000000_create_orders.rb:8:5: C: Custom/MigrationForeignKey: add_foreign_keyメソッドの使用は禁止されています。
add_foreign_key :orders, :shops
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
まとめ
RuboCopを動かすたびに内部でどうやってチェックしているのか疑問でしたが、思いのほか簡単に自作できました。ASTとコールバックの仕組みさえ理解すれば、プロジェクト固有のルールもすぐに実装できます。
他にもレビュー漏れが発生しやすいものについては、同様にカスタムCopを作ってチェックするようにしています。
最近はAIに「こういうチェックをするRuboCop Copを作って」と頼めばサクッと作ってくれるので、欲しいCopが見つからなければ自分で作ってみるのもおすすめです。