forked from rubocop/rubocop-rails
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathinverse_of.rb
242 lines (215 loc) · 7.39 KB
/
inverse_of.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
# frozen_string_literal: true
module RuboCop
module Cop
module Rails
# This cop looks for has_(one|many) and belongs_to associations where
# Active Record can't automatically determine the inverse association
# because of a scope or the options used. Using the blog with order scope
# example below, traversing the a Blog's association in both directions
# with `blog.posts.first.blog` would cause the `blog` to be loaded from
# the database twice.
#
# `:inverse_of` must be manually specified for Active Record to use the
# associated object in memory, or set to `false` to opt-out. Note that
# setting `nil` does not stop Active Record from trying to determine the
# inverse automatically, and is not considered a valid value for this.
#
# @example
# # good
# class Blog < ApplicationRecord
# has_many :posts
# end
#
# class Post < ApplicationRecord
# belongs_to :blog
# end
#
# @example
# # bad
# class Blog < ApplicationRecord
# has_many :posts, -> { order(published_at: :desc) }
# end
#
# class Post < ApplicationRecord
# belongs_to :blog
# end
#
# # good
# class Blog < ApplicationRecord
# has_many(:posts,
# -> { order(published_at: :desc) },
# inverse_of: :blog)
# end
#
# class Post < ApplicationRecord
# belongs_to :blog
# end
#
# # good
# class Blog < ApplicationRecord
# with_options inverse_of: :blog do
# has_many :posts, -> { order(published_at: :desc) }
# end
# end
#
# class Post < ApplicationRecord
# belongs_to :blog
# end
#
# # good
# # When you don't want to use the inverse association.
# class Blog < ApplicationRecord
# has_many(:posts,
# -> { order(published_at: :desc) },
# inverse_of: false)
# end
#
# @example
# # bad
# class Picture < ApplicationRecord
# belongs_to :imageable, polymorphic: true
# end
#
# class Employee < ApplicationRecord
# has_many :pictures, as: :imageable
# end
#
# class Product < ApplicationRecord
# has_many :pictures, as: :imageable
# end
#
# # good
# class Picture < ApplicationRecord
# belongs_to :imageable, polymorphic: true
# end
#
# class Employee < ApplicationRecord
# has_many :pictures, as: :imageable, inverse_of: :imageable
# end
#
# class Product < ApplicationRecord
# has_many :pictures, as: :imageable, inverse_of: :imageable
# end
#
# @example
# # bad
# # However, RuboCop can not detect this pattern...
# class Physician < ApplicationRecord
# has_many :appointments
# has_many :patients, through: :appointments
# end
#
# class Appointment < ApplicationRecord
# belongs_to :physician
# belongs_to :patient
# end
#
# class Patient < ApplicationRecord
# has_many :appointments
# has_many :physicians, through: :appointments
# end
#
# # good
# class Physician < ApplicationRecord
# has_many :appointments
# has_many :patients, through: :appointments
# end
#
# class Appointment < ApplicationRecord
# belongs_to :physician, inverse_of: :appointments
# belongs_to :patient, inverse_of: :appointments
# end
#
# class Patient < ApplicationRecord
# has_many :appointments
# has_many :physicians, through: :appointments
# end
#
# @see https://guides.rubyonrails.org/association_basics.html#bi-directional-associations
# @see https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#module-ActiveRecord::Associations::ClassMethods-label-Setting+Inverses
class InverseOf < Base
SPECIFY_MSG = 'Specify an `:inverse_of` option.'
NIL_MSG = 'You specified `inverse_of: nil`, you probably meant to use `inverse_of: false`.'
RESTRICT_ON_SEND = %i[has_many has_one belongs_to].freeze
def_node_matcher :association_recv_arguments, <<~PATTERN
(send $_ {:has_many :has_one :belongs_to} _ $...)
PATTERN
def_node_matcher :options_from_argument, <<~PATTERN
(hash $...)
PATTERN
def_node_matcher :conditions_option?, <<~PATTERN
(pair (sym :conditions) !nil)
PATTERN
def_node_matcher :through_option?, <<~PATTERN
(pair (sym :through) !nil)
PATTERN
def_node_matcher :polymorphic_option?, <<~PATTERN
(pair (sym :polymorphic) !nil)
PATTERN
def_node_matcher :as_option?, <<~PATTERN
(pair (sym :as) !nil)
PATTERN
def_node_matcher :foreign_key_option?, <<~PATTERN
(pair (sym :foreign_key) !nil)
PATTERN
def_node_matcher :inverse_of_option?, <<~PATTERN
(pair (sym :inverse_of) !nil)
PATTERN
def_node_matcher :inverse_of_nil_option?, <<~PATTERN
(pair (sym :inverse_of) nil)
PATTERN
def on_send(node)
recv, arguments = association_recv_arguments(node)
return unless arguments
with_options = with_options_arguments(recv, node)
options = arguments.concat(with_options).flat_map do |arg|
options_from_argument(arg)
end
return if options_ignoring_inverse_of?(options)
return unless scope?(arguments) ||
options_requiring_inverse_of?(options)
return if options_contain_inverse_of?(options)
add_offense(node.loc.selector, message: message(options))
end
def scope?(arguments)
arguments.any?(&:block_type?)
end
def options_requiring_inverse_of?(options)
required = options.any? do |opt|
conditions_option?(opt) ||
foreign_key_option?(opt)
end
return required if target_rails_version >= 5.2
required || options.any? { |opt| as_option?(opt) }
end
def options_ignoring_inverse_of?(options)
options.any? do |opt|
through_option?(opt) || polymorphic_option?(opt)
end
end
def options_contain_inverse_of?(options)
options.any? { |opt| inverse_of_option?(opt) }
end
def with_options_arguments(recv, node)
blocks = node.each_ancestor(:block).select do |block|
block.send_node.command?(:with_options) &&
same_context_in_with_options?(block.arguments.first, recv)
end
blocks.flat_map { |n| n.send_node.arguments }
end
def same_context_in_with_options?(arg, recv)
return true if arg.nil? && recv.nil?
arg && recv && arg.children[0] == recv.children[0]
end
private
def message(options)
if options.any? { |opt| inverse_of_nil_option?(opt) }
NIL_MSG
else
SPECIFY_MSG
end
end
end
end
end
end