Skip to content

Commit 48c0611

Browse files
authored
Add TooManyMigrationRuns cop to test performance (#4850)
1 parent b7b7805 commit 48c0611

7 files changed

+298
-73
lines changed

.rubocop_cc.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ require:
1212
- ./spec/linters/migration/add_constraint_name.rb
1313
- ./spec/linters/migration/include_string_size.rb
1414
- ./spec/linters/migration/require_primary_key.rb
15+
- ./spec/linters/migration/too_many_migration_runs.rb
1516
- ./spec/linters/match_requires_with_includes.rb
1617
- ./spec/linters/prefer_oj_over_other_json_libraries.rb
1718

@@ -114,7 +115,9 @@ Style/HashSyntax:
114115
EnforcedShorthandSyntax: consistent
115116
Style/RaiseArgs:
116117
EnforcedStyle: compact
117-
118+
Migration/TooManyMigrationRuns:
119+
Exclude:
120+
- spec/linters/migration/too_many_migration_runs.rb
118121

119122
#### ENABLED SECTION
120123
Capybara/ClickLinkOrButtonStyle:
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
module RuboCop
2+
module Cop
3+
module Migration
4+
class TooManyMigrationRuns < Base
5+
def on_new_investigation
6+
return unless processed_source.ast
7+
8+
definitions = extract_migrator_definitions
9+
call_count = count_migrator_calls(definitions)
10+
11+
return unless call_count > 4
12+
13+
add_offense(processed_source.ast,
14+
message: "Too many migration runs (#{call_count}). Combine tests to reduce migrations. See spec/migrations/README.md for further guidance.")
15+
end
16+
17+
private
18+
19+
def extract_migrator_definitions
20+
definitions = {
21+
subject_names: [],
22+
method_names: [],
23+
let_names: [],
24+
before_after_blocks: Set.new
25+
}
26+
27+
# Single pass through the AST to collect all definitions
28+
processed_source.ast.each_descendant(:def, :block) do |node|
29+
case node.type
30+
when :def
31+
extract_migrator_method(node, definitions[:method_names])
32+
when :block
33+
extract_block_definitions(node, definitions)
34+
end
35+
end
36+
37+
definitions
38+
end
39+
40+
def extract_migrator_method(node, method_names)
41+
return unless contains_migrator_run?(node)
42+
43+
method_names << node.method_name
44+
end
45+
46+
def extract_block_definitions(node, definitions)
47+
return unless node.send_node
48+
49+
method_name = node.send_node.method_name
50+
51+
case method_name
52+
when :subject
53+
extract_named_migrator(node, definitions[:subject_names])
54+
when :let, :let!
55+
extract_named_migrator(node, definitions[:let_names])
56+
when :before, :after, :around
57+
definitions[:before_after_blocks].add(node.object_id) if contains_migrator_run?(node)
58+
end
59+
end
60+
61+
def extract_named_migrator(node, names)
62+
return unless contains_migrator_run?(node)
63+
64+
first_arg = node.send_node.first_argument
65+
names << first_arg.value if first_arg&.sym_type?
66+
end
67+
68+
def count_migrator_calls(definitions)
69+
call_count = 0
70+
71+
# Single pass through send nodes to count all migration calls
72+
processed_source.ast.each_descendant(:send) do |node|
73+
next unless migration_call?(node, definitions)
74+
75+
call_count += 1
76+
end
77+
78+
call_count
79+
end
80+
81+
def migration_call?(node, definitions)
82+
in_before_after_block = node.each_ancestor(:block).any? do |ancestor|
83+
definitions[:before_after_blocks].include?(ancestor.object_id)
84+
end
85+
86+
if in_before_after_block
87+
# Count direct Migrator.run calls inside before/after/around blocks
88+
migrator_run_call?(node)
89+
else
90+
# Count direct calls (not in definitions) or helper invocations
91+
direct_migrator_call?(node) || helper_migration_call?(node, definitions)
92+
end
93+
end
94+
95+
def direct_migrator_call?(node)
96+
return false unless migrator_run_call?(node)
97+
98+
!inside_definition?(node)
99+
end
100+
101+
def migrator_run_call?(node)
102+
return false unless node.method_name == :run
103+
104+
receiver = node.receiver
105+
return false unless receiver
106+
107+
# Check for Sequel::Migrator.run or just Migrator.run
108+
if receiver.const_type?
109+
receiver_name = receiver.const_name
110+
return ['Migrator', 'Sequel::Migrator'].include?(receiver_name)
111+
end
112+
113+
false
114+
end
115+
116+
def helper_migration_call?(node, definitions)
117+
method = node.method_name
118+
definitions[:subject_names].include?(method) ||
119+
definitions[:let_names].include?(method) ||
120+
definitions[:method_names].include?(method)
121+
end
122+
123+
def contains_migrator_run?(node)
124+
node.each_descendant(:send).any? { |send_node| migrator_run_call?(send_node) }
125+
end
126+
127+
def inside_definition?(node)
128+
node.each_ancestor(:def, :block).any? do |ancestor|
129+
case ancestor.type
130+
when :def
131+
contains_migrator_run?(ancestor)
132+
when :block
133+
next false unless ancestor.send_node
134+
135+
method = ancestor.send_node.method_name
136+
if %i[subject let let!].include?(method)
137+
contains_migrator_run?(ancestor)
138+
else
139+
%i[before after around].include?(method)
140+
end
141+
end
142+
end
143+
end
144+
end
145+
end
146+
end
147+
end
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
require 'rubocop'
2+
require 'rubocop/rspec/cop_helper'
3+
require 'rubocop/config'
4+
require 'linters/migration/too_many_migration_runs'
5+
6+
RSpec.describe RuboCop::Cop::Migration::TooManyMigrationRuns do
7+
include CopHelper
8+
9+
subject(:cop) { described_class.new(RuboCop::Config.new({})) }
10+
11+
def migration_call(target)
12+
"Sequel::Migrator.run(db, path, target: #{target})"
13+
end
14+
15+
def it_blocks(count)
16+
(1..count).map { |n| "it('test #{n}') { #{migration_call(n)} }" }.join("\n")
17+
end
18+
19+
it 'does not register an offense for 4 or fewer direct migration calls' do
20+
result = inspect_source("RSpec.describe('m') do\n#{it_blocks(4)}\nend")
21+
expect(result).to be_empty
22+
end
23+
24+
it 'registers an offense for more than 4 direct migration calls' do
25+
result = inspect_source("RSpec.describe('m') do\n#{it_blocks(5)}\nend")
26+
expect(result.size).to eq(1)
27+
expect(result.first.message).to include('(5)')
28+
end
29+
30+
it 'counts multiple migrations in a single it block' do
31+
source = <<~RUBY
32+
RSpec.describe('m') do
33+
it('test') do
34+
#{(1..5).map { |n| migration_call(n) }.join("\n")}
35+
end
36+
end
37+
RUBY
38+
result = inspect_source(source)
39+
expect(result.size).to eq(1)
40+
expect(result.first.message).to include('(5)')
41+
end
42+
43+
it 'counts migrations via subject, let, let!, and helper methods' do
44+
source = <<~RUBY
45+
RSpec.describe('m') do
46+
subject(:migrate_subj) { #{migration_call(1)} }
47+
let(:migrate_let) { #{migration_call(2)} }
48+
let!(:migrate_let_bang) { #{migration_call(3)} }
49+
def migrate_method; #{migration_call(4)}; end
50+
51+
it('t1') { migrate_subj }
52+
it('t2') { migrate_let }
53+
it('t3') { migrate_let_bang }
54+
it('t4') { migrate_method }
55+
it('t5') { migrate_subj }
56+
end
57+
RUBY
58+
result = inspect_source(source)
59+
expect(result.size).to eq(1)
60+
expect(result.first.message).to include('(5)')
61+
end
62+
63+
it 'does not double-count definitions - only invocations' do
64+
source = <<~RUBY
65+
RSpec.describe('m') do
66+
subject(:migrate) { #{migration_call(1)} }
67+
it('t1') { migrate }
68+
it('t2') { migrate }
69+
it('t3') { migrate }
70+
it('t4') { migrate }
71+
end
72+
RUBY
73+
result = inspect_source(source)
74+
expect(result).to be_empty
75+
end
76+
77+
it 'counts migrations in before/after blocks' do
78+
source = <<~RUBY
79+
RSpec.describe('m') do
80+
before { #{migration_call(1)}; #{migration_call(2)} }
81+
after { #{migration_call(3)} }
82+
it('t1') { #{migration_call(4)} }
83+
it('t2') { #{migration_call(5)} }
84+
end
85+
RUBY
86+
result = inspect_source(source)
87+
expect(result.size).to eq(1)
88+
expect(result.first.message).to include('(5)')
89+
end
90+
91+
it 'does not count non-migration let invocations' do
92+
source = <<~RUBY
93+
RSpec.describe('m') do
94+
let(:value) { 'not a migration' }
95+
#{(1..4).map { |n| "it('t#{n}') { value; #{migration_call(n)} }" }.join("\n")}
96+
end
97+
RUBY
98+
result = inspect_source(source)
99+
expect(result).to be_empty
100+
end
101+
102+
it 'handles empty files and files without migrations' do
103+
expect(inspect_source('')).to be_empty
104+
expect(inspect_source("RSpec.describe('x') { it('y') { expect(1).to eq(1) } }")).to be_empty
105+
end
106+
107+
it 'detects ::Sequel::Migrator.run and bare Migrator.run' do
108+
%w[::Sequel::Migrator Migrator].each do |const|
109+
source = "RSpec.describe('m') do\n#{(1..5).map { |n| "it('t#{n}') { #{const}.run(db, path, target: #{n}) }" }.join("\n")}\nend"
110+
result = inspect_source(source)
111+
expect(result.size).to eq(1), "Expected offense for #{const}.run"
112+
end
113+
end
114+
end

spec/migrations/20241016118000_drop_unique_constraint_quota_definitions_name_key_spec.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# rubocop:disable Migration/TooManyMigrationRuns
12
require 'spec_helper'
23
require 'migrations/helpers/migration_shared_context'
34

@@ -53,3 +54,4 @@
5354
end
5455
end
5556
end
57+
# rubocop:enable Migration/TooManyMigrationRuns

0 commit comments

Comments
 (0)