Skip to content

Commit 535949c

Browse files
committed
Add TooManyMigrationRuns cop to test performance
Enforces max 4 migration runs per test file to prevent expensive repeated migrations. Detects subjects, let helpers, helper methods, and before/after blocks. Intentionally simple to avoid false positives on edge cases. Also reduce number of migration calls in `_add_state_to_stacks_spec` to please Rubbcop. Disable cop for `bigint_migration_*_shared_context`.
1 parent 6d5b46b commit 535949c

File tree

5 files changed

+170
-73
lines changed

5 files changed

+170
-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: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
module RuboCop
2+
module Cop
3+
module Migration
4+
class TooManyMigrationRuns < Base
5+
MSG = 'Too many migration runs (%d). Combine tests to reduce migrations. See spec/migrations/README.md for further guidance.'.freeze
6+
MAX_CALLS = 4
7+
8+
def on_new_investigation
9+
calls = 0
10+
migrator_subject_names = []
11+
migrator_method_names = []
12+
migrator_let_names = []
13+
migrator_before_after_blocks = Set.new
14+
15+
extract_migrator_definitions(migrator_subject_names, migrator_method_names,
16+
migrator_let_names, migrator_before_after_blocks)
17+
18+
count_migrator_calls(calls, migrator_subject_names, migrator_method_names,
19+
migrator_let_names, migrator_before_after_blocks)
20+
end
21+
22+
def extract_migrator_definitions(subject_names, method_names, let_names, before_after_blocks)
23+
processed_source.ast.each_descendant(:def) do |node|
24+
method_name = extract_migrator_method_name(node)
25+
method_names << method_name if method_name
26+
end
27+
28+
processed_source.ast.each_descendant(:block) do |node|
29+
subject_name = extract_migrator_subject_name(node)
30+
subject_names << subject_name if subject_name
31+
32+
let_name = extract_migrator_let_name(node)
33+
let_names << let_name if let_name
34+
35+
before_after_blocks.add(node.object_id) if is_before_after_around_with_migrator?(node)
36+
end
37+
end
38+
39+
def count_migrator_calls(_calls, subjects, methods, lets, before_after_blocks)
40+
call_count = count_before_after_migrations(before_after_blocks)
41+
call_count += count_send_node_migrations(subjects, methods, lets, before_after_blocks)
42+
43+
add_offense(processed_source.ast, message: sprintf(MSG, call_count)) if call_count > MAX_CALLS
44+
end
45+
46+
def count_before_after_migrations(before_after_blocks)
47+
call_count = 0
48+
processed_source.ast.each_descendant(:block) do |node|
49+
call_count += count_direct_migrations_in_node(node) if before_after_blocks.include?(node.object_id)
50+
end
51+
call_count
52+
end
53+
54+
def count_send_node_migrations(subjects, methods, lets, before_after_blocks)
55+
call_count = 0
56+
processed_source.ast.each_descendant(:send) do |node|
57+
next if node.each_ancestor(:block).any? { |a| before_after_blocks.include?(a.object_id) }
58+
59+
call_count += count_migration_call(node, subjects, methods, lets)
60+
end
61+
call_count
62+
end
63+
64+
def count_migration_call(node, subjects, methods, lets)
65+
return 1 if direct_migrator_call?(node)
66+
return 1 if helper_migration_call?(node, subjects, methods, lets)
67+
68+
0
69+
end
70+
71+
def direct_migrator_call?(node)
72+
return false unless node.method_name == :run && node.receiver&.source&.include?('Migrator')
73+
74+
!inside_definition?(node)
75+
end
76+
77+
def helper_migration_call?(node, subjects, methods, lets)
78+
subjects.include?(node.method_name) ||
79+
lets.include?(node.method_name) ||
80+
methods.include?(node.method_name)
81+
end
82+
83+
private
84+
85+
def extract_migrator_method_name(node)
86+
return nil unless node.type == :def
87+
return nil unless node.source.include?('Sequel::Migrator.run')
88+
89+
node.method_name
90+
end
91+
92+
def extract_migrator_subject_name(node)
93+
return nil unless node.send_node.method_name == :subject
94+
return nil unless node.source.include?('Sequel::Migrator.run')
95+
96+
first_arg = node.send_node.first_argument
97+
first_arg&.sym_type? ? first_arg.value : nil
98+
end
99+
100+
def extract_migrator_let_name(node)
101+
return nil unless %i[let let!].include?(node.send_node.method_name)
102+
return nil unless node.source.include?('Sequel::Migrator.run')
103+
104+
first_arg = node.send_node.first_argument
105+
first_arg&.sym_type? ? first_arg.value : nil
106+
end
107+
108+
def is_before_after_around_with_migrator?(node)
109+
return false unless node.send_node
110+
return false unless %i[before after around].include?(node.send_node.method_name)
111+
112+
node.source.include?('Sequel::Migrator.run')
113+
end
114+
115+
def count_direct_migrations_in_node(node)
116+
count = 0
117+
node.each_descendant(:send) do |descendant|
118+
count += 1 if descendant.method_name == :run && descendant.receiver&.source&.include?('Migrator')
119+
end
120+
count
121+
end
122+
123+
def inside_definition?(node)
124+
node.each_ancestor(:def).any? { |a| a.source.include?('Sequel::Migrator.run') } ||
125+
node.each_ancestor(:block).any? do |a|
126+
%i[subject let let!].include?(a.send_node&.method_name) && a.source.include?('Sequel::Migrator.run')
127+
end ||
128+
node.each_ancestor(:block).any? do |a|
129+
%i[before after around].include?(a.send_node&.method_name)
130+
end
131+
end
132+
end
133+
end
134+
end
135+
end

spec/migrations/20251117123719_add_state_to_stacks_spec.rb

Lines changed: 27 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,90 +2,45 @@
22
require 'migrations/helpers/migration_shared_context'
33

44
RSpec.describe 'migration to add state column to stacks table', isolation: :truncation, type: :migration do
5+
subject(:run_migration) { Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) }
6+
57
include_context 'migration' do
68
let(:migration_filename) { '20251117123719_add_state_to_stacks.rb' }
79
end
810

9-
describe 'stacks table' do
10-
subject(:run_migration) { Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) }
11-
12-
describe 'up' do
13-
it 'adds a column `state`' do
14-
expect(db[:stacks].columns).not_to include(:state)
15-
run_migration
16-
expect(db[:stacks].columns).to include(:state)
17-
end
18-
19-
it 'sets the default value of existing stacks to ACTIVE' do
20-
db[:stacks].insert(guid: SecureRandom.uuid, name: 'existing-stack', description: 'An existing stack')
21-
run_migration
22-
expect(db[:stacks].first(name: 'existing-stack')[:state]).to eq('ACTIVE')
23-
end
11+
it 'adds state column with defaults/constraints (up) and removes it (down), idempotently' do
12+
# Setup: insert existing stack before migration
13+
db[:stacks].insert(guid: SecureRandom.uuid, name: 'existing-stack', description: 'An existing stack')
14+
expect(db[:stacks].columns).not_to include(:state)
2415

25-
it 'sets the default value of new stacks to ACTIVE' do
26-
run_migration
27-
db[:stacks].insert(guid: SecureRandom.uuid, name: 'new-stack', description: 'A new stack')
28-
expect(db[:stacks].first(name: 'new-stack')[:state]).to eq('ACTIVE')
29-
end
16+
# Run migration UP
17+
run_migration
3018

31-
it 'forbids null values' do
32-
run_migration
33-
expect do
34-
db[:stacks].insert(guid: SecureRandom.uuid, name: 'null-state-stack', description: 'A stack with null state', state: nil)
35-
end.to raise_error(Sequel::NotNullConstraintViolation)
36-
end
19+
# Verify column was added with correct behavior
20+
expect(db[:stacks].columns).to include(:state)
21+
expect(db[:stacks].first(name: 'existing-stack')[:state]).to eq('ACTIVE')
3722

38-
it 'allows valid state values' do
39-
run_migration
40-
%w[ACTIVE DEPRECATED RESTRICTED DISABLED].each do |state|
41-
expect do
42-
db[:stacks].insert(guid: SecureRandom.uuid, name: "stack-#{state.downcase}", description: "A #{state} stack", state: state)
43-
end.not_to raise_error
44-
expect(db[:stacks].first(name: "stack-#{state.downcase}")[:state]).to eq(state)
45-
end
46-
end
23+
db[:stacks].insert(guid: SecureRandom.uuid, name: 'new-stack', description: 'A new stack')
24+
expect(db[:stacks].first(name: 'new-stack')[:state]).to eq('ACTIVE')
4725

48-
context 'when the column already exists' do
49-
before do
50-
db.alter_table :stacks do
51-
add_column :state, String, null: false, default: 'ACTIVE', size: 255 unless @db.schema(:stacks).map(&:first).include?(:state)
52-
end
53-
end
26+
expect do
27+
db[:stacks].insert(guid: SecureRandom.uuid, name: 'null-state-stack', description: 'A stack with null state', state: nil)
28+
end.to raise_error(Sequel::NotNullConstraintViolation)
5429

55-
it 'does not fail' do
56-
expect(db[:stacks].columns).to include(:state)
57-
expect { run_migration }.not_to raise_error
58-
expect(db[:stacks].columns).to include(:state)
59-
end
60-
end
30+
%w[DEPRECATED RESTRICTED DISABLED].each do |state|
31+
db[:stacks].insert(guid: SecureRandom.uuid, name: "stack-#{state.downcase}", description: "A #{state} stack", state: state)
32+
expect(db[:stacks].first(name: "stack-#{state.downcase}")[:state]).to eq(state)
6133
end
6234

63-
describe 'down' do
64-
subject(:run_rollback) { Sequel::Migrator.run(db, migrations_path, target: current_migration_index - 1, allow_missing_migration_files: true) }
35+
# Verify UP is idempotent
36+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) }.not_to raise_error
6537

66-
before do
67-
run_migration
68-
end
38+
# Run migration DOWN
39+
Sequel::Migrator.run(db, migrations_path, target: current_migration_index - 1, allow_missing_migration_files: true)
40+
expect(db[:stacks].columns).not_to include(:state)
6941

70-
it 'removes the `state` column' do
71-
expect(db[:stacks].columns).to include(:state)
72-
run_rollback
73-
expect(db[:stacks].columns).not_to include(:state)
74-
end
75-
76-
context 'when the column does not exist' do
77-
before do
78-
db.alter_table :stacks do
79-
drop_column :state if @db.schema(:stacks).map(&:first).include?(:state)
80-
end
81-
end
82-
83-
it 'does not fail' do
84-
expect(db[:stacks].columns).not_to include(:state)
85-
expect { run_rollback }.not_to raise_error
86-
expect(db[:stacks].columns).not_to include(:state)
87-
end
88-
end
89-
end
42+
# Verify DOWN is idempotent
43+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index - 1, allow_missing_migration_files: true) }.not_to raise_error
44+
expect(db[:stacks].columns).not_to include(:state)
9045
end
9146
end

spec/migrations/helpers/bigint_migration_step1_shared_context.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 'migrations/helpers/migration_shared_context'
23
require 'database/bigint_migration'
34

@@ -183,3 +184,4 @@
183184
end
184185
end
185186
end
187+
# rubocop:enable Migration/TooManyMigrationRuns

spec/migrations/helpers/bigint_migration_step3_shared_context.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 'migrations/helpers/migration_shared_context'
23
require 'database/bigint_migration'
34

@@ -218,3 +219,4 @@
218219
end
219220
end
220221
end
222+
# rubocop:enable Migration/TooManyMigrationRuns

0 commit comments

Comments
 (0)