Skip to content

Commit 17ebff7

Browse files
authored
Merge pull request #609 from pirj/fix-603-multi-attribute-validation
[Fix #603] Teach RedundantPresenceValidationOnBelongsTo to work with multi-attribute validations
2 parents 210c368 + 1d7a9ed commit 17ebff7

File tree

3 files changed

+153
-25
lines changed

3 files changed

+153
-25
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* [#603](https://github.com/rubocop/rubocop-rails/issues/603): Fix autocorrection of multiple attributes for `Rails/RedundantPresenceValidationOnBelongsTo` cop. ([@pirj][])

lib/rubocop/cop/rails/redundant_presence_validation_on_belongs_to.rb

Lines changed: 80 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class RedundantPresenceValidationOnBelongsTo < Base
3232
extend AutoCorrector
3333
extend TargetRailsVersion
3434

35-
MSG = 'Remove explicit presence validation for `%<association>s`.'
35+
MSG = 'Remove explicit presence validation for %<association>s.'
3636
RESTRICT_ON_SEND = %i[validates].freeze
3737

3838
minimum_target_rails_version 5.0
@@ -43,28 +43,30 @@ class RedundantPresenceValidationOnBelongsTo < Base
4343
# @example source that matches - by association
4444
# validates :user, presence: true
4545
#
46+
# @example source that matches - by association
47+
# validates :name, :user, presence: true
48+
#
4649
# @example source that matches - with presence options
4750
# validates :user, presence: { message: 'duplicate' }
4851
#
4952
# @example source that matches - by a foreign key
5053
# validates :user_id, presence: true
5154
def_node_matcher :presence_validation?, <<~PATTERN
52-
$(
55+
(
5356
send nil? :validates
54-
(sym $_)
55-
...
57+
(sym $_)+
5658
$(hash <$(pair (sym :presence) {true hash}) ...>)
5759
)
5860
PATTERN
5961

60-
# @!method optional_option?(node)
61-
# Match a `belongs_to` association with an optional option in a hash
62+
# @!method optional?(node)
63+
# Match a `belongs_to` association with an optional option in a hash
6264
def_node_matcher :optional?, <<~PATTERN
6365
(send nil? :belongs_to _ ... #optional_option?)
6466
PATTERN
6567

6668
# @!method optional_option?(node)
67-
# Match an optional option in a hash
69+
# Match an optional option in a hash
6870
def_node_matcher :optional_option?, <<~PATTERN
6971
{
7072
(hash <(pair (sym :optional) true) ...>) # optional: true
@@ -122,7 +124,7 @@ class RedundantPresenceValidationOnBelongsTo < Base
122124
)
123125
PATTERN
124126

125-
# @!method belongs_to_without_fk?(node, fk)
127+
# @!method belongs_to_without_fk?(node, key)
126128
# Match a matching `belongs_to` association, without an explicit `foreign_key` option
127129
#
128130
# @param node [RuboCop::AST::Node]
@@ -150,21 +152,43 @@ class RedundantPresenceValidationOnBelongsTo < Base
150152
PATTERN
151153

152154
def on_send(node)
153-
validation, key, options, presence = presence_validation?(node)
154-
return unless validation
155+
presence_validation?(node) do |all_keys, options, presence|
156+
keys = non_optional_belongs_to(node.parent, all_keys)
157+
return if keys.none?
155158

156-
belongs_to = belongs_to_for(node.parent, key)
157-
return unless belongs_to
158-
return if optional?(belongs_to)
159+
add_offense_and_correct(node, all_keys, keys, options, presence)
160+
end
161+
end
159162

160-
message = format(MSG, association: key.to_s)
163+
private
161164

162-
add_offense(presence, message: message) do |corrector|
163-
remove_presence_validation(corrector, node, options, presence)
165+
def add_offense_and_correct(node, all_keys, keys, options, presence)
166+
add_offense(presence, message: message_for(keys)) do |corrector|
167+
if options.children.one? # `presence: true` is the only option
168+
if keys == all_keys
169+
remove_validation(corrector, node)
170+
else
171+
remove_keys_from_validation(corrector, node, keys)
172+
end
173+
elsif keys == all_keys
174+
remove_presence_option(corrector, presence)
175+
else
176+
extract_validation_for_keys(corrector, node, keys, options)
177+
end
164178
end
165179
end
166180

167-
private
181+
def message_for(keys)
182+
display_keys = keys.map { |key| "`#{key}`" }.join('/')
183+
format(MSG, association: display_keys)
184+
end
185+
186+
def non_optional_belongs_to(node, keys)
187+
keys.select do |key|
188+
belongs_to = belongs_to_for(node, key)
189+
belongs_to && !optional?(belongs_to)
190+
end
191+
end
168192

169193
def belongs_to_for(model_class_node, key)
170194
if key.to_s.end_with?('_id')
@@ -175,17 +199,48 @@ def belongs_to_for(model_class_node, key)
175199
end
176200
end
177201

178-
def remove_presence_validation(corrector, node, options, presence)
179-
if options.children.one?
180-
corrector.remove(range_by_whole_lines(node.source_range, include_final_newline: true))
181-
else
182-
range = range_with_surrounding_comma(
183-
range_with_surrounding_space(range: presence.source_range, side: :left),
184-
:left
202+
def remove_validation(corrector, node)
203+
corrector.remove(validation_range(node))
204+
end
205+
206+
def remove_keys_from_validation(corrector, node, keys)
207+
keys.each do |key|
208+
key_node = node.arguments.find { |arg| arg.value == key }
209+
key_range = range_with_surrounding_space(
210+
range: range_with_surrounding_comma(key_node.source_range, :right),
211+
side: :right
185212
)
186-
corrector.remove(range)
213+
corrector.remove(key_range)
187214
end
188215
end
216+
217+
def remove_presence_option(corrector, presence)
218+
range = range_with_surrounding_comma(
219+
range_with_surrounding_space(range: presence.source_range, side: :left),
220+
:left
221+
)
222+
corrector.remove(range)
223+
end
224+
225+
def extract_validation_for_keys(corrector, node, keys, options)
226+
indentation = ' ' * node.source_range.column
227+
options_without_presence = options.children.reject { |pair| pair.key.value == :presence }
228+
source = [
229+
indentation,
230+
'validates ',
231+
keys.map(&:inspect).join(', '),
232+
', ',
233+
options_without_presence.map(&:source).join(', '),
234+
"\n"
235+
].join
236+
237+
remove_keys_from_validation(corrector, node, keys)
238+
corrector.insert_after(validation_range(node), source)
239+
end
240+
241+
def validation_range(node)
242+
range_by_whole_lines(node.source_range, include_final_newline: true)
243+
end
189244
end
190245
end
191246
end

spec/rubocop/cop/rails/redundant_presence_validation_on_belongs_to_spec.rb

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,78 @@
101101
RUBY
102102
end
103103

104+
it 'registers an offense for multiple associations' do
105+
expect_offense(<<~RUBY)
106+
belongs_to :user
107+
belongs_to :book
108+
validates :user, :book, presence: true
109+
^^^^^^^^^^^^^^ Remove explicit presence validation for `user`/`book`.
110+
RUBY
111+
112+
expect_correction(<<~RUBY)
113+
belongs_to :user
114+
belongs_to :book
115+
RUBY
116+
end
117+
118+
it 'registers an offense for multiple attributes when not all are associations' do
119+
expect_offense(<<~RUBY)
120+
belongs_to :user
121+
validates :user, :name, presence: true
122+
^^^^^^^^^^^^^^ Remove explicit presence validation for `user`.
123+
RUBY
124+
125+
expect_correction(<<~RUBY)
126+
belongs_to :user
127+
validates :name, presence: true
128+
RUBY
129+
end
130+
131+
it 'registers an offense for a secondary attribute' do
132+
expect_offense(<<~RUBY)
133+
belongs_to :user
134+
validates :name, :user, presence: true
135+
^^^^^^^^^^^^^^ Remove explicit presence validation for `user`.
136+
RUBY
137+
138+
expect_correction(<<~RUBY)
139+
belongs_to :user
140+
validates :name, presence: true
141+
RUBY
142+
end
143+
144+
it 'registers an offense for multiple attributes and options' do
145+
expect_offense(<<~RUBY)
146+
belongs_to :user
147+
validates :user, :name, presence: true, uniqueness: true
148+
^^^^^^^^^^^^^^ Remove explicit presence validation for `user`.
149+
RUBY
150+
151+
expect_correction(<<~RUBY)
152+
belongs_to :user
153+
validates :name, presence: true, uniqueness: true
154+
validates :user, uniqueness: true
155+
RUBY
156+
end
157+
158+
it 'preserves indentation for the extracted validation line' do
159+
expect_offense(<<~RUBY)
160+
class Profile
161+
belongs_to :user
162+
validates :user, :name, presence: true, uniqueness: true
163+
^^^^^^^^^^^^^^ Remove explicit presence validation for `user`.
164+
end
165+
RUBY
166+
167+
expect_correction(<<~RUBY)
168+
class Profile
169+
belongs_to :user
170+
validates :name, presence: true, uniqueness: true
171+
validates :user, uniqueness: true
172+
end
173+
RUBY
174+
end
175+
104176
it 'registers an offense for presence with a message' do
105177
expect_offense(<<~RUBY)
106178
belongs_to :user

0 commit comments

Comments
 (0)