rubocop-rails 2.23.1 → 2.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -2
  3. data/config/default.yml +32 -7
  4. data/lib/rubocop/cop/mixin/active_record_helper.rb +6 -1
  5. data/lib/rubocop/cop/mixin/target_rails_version.rb +29 -2
  6. data/lib/rubocop/cop/rails/action_controller_flash_before_render.rb +2 -0
  7. data/lib/rubocop/cop/rails/action_order.rb +1 -5
  8. data/lib/rubocop/cop/rails/active_record_callbacks_order.rb +1 -5
  9. data/lib/rubocop/cop/rails/active_support_on_load.rb +21 -1
  10. data/lib/rubocop/cop/rails/application_record.rb +4 -0
  11. data/lib/rubocop/cop/rails/bulk_change_table.rb +10 -4
  12. data/lib/rubocop/cop/rails/compact_blank.rb +29 -8
  13. data/lib/rubocop/cop/rails/dangerous_column_names.rb +1 -2
  14. data/lib/rubocop/cop/rails/date.rb +2 -2
  15. data/lib/rubocop/cop/rails/enum_hash.rb +31 -8
  16. data/lib/rubocop/cop/rails/enum_syntax.rb +128 -0
  17. data/lib/rubocop/cop/rails/enum_uniqueness.rb +29 -7
  18. data/lib/rubocop/cop/rails/expanded_date_range.rb +1 -1
  19. data/lib/rubocop/cop/rails/file_path.rb +1 -1
  20. data/lib/rubocop/cop/rails/find_by.rb +1 -1
  21. data/lib/rubocop/cop/rails/http_status.rb +12 -2
  22. data/lib/rubocop/cop/rails/ignored_skip_action_filter_option.rb +1 -1
  23. data/lib/rubocop/cop/rails/link_to_blank.rb +2 -2
  24. data/lib/rubocop/cop/rails/not_null_column.rb +93 -13
  25. data/lib/rubocop/cop/rails/pick.rb +4 -0
  26. data/lib/rubocop/cop/rails/pluck_in_where.rb +17 -8
  27. data/lib/rubocop/cop/rails/pluralization_grammar.rb +29 -15
  28. data/lib/rubocop/cop/rails/present.rb +0 -2
  29. data/lib/rubocop/cop/rails/redundant_active_record_all_method.rb +0 -29
  30. data/lib/rubocop/cop/rails/redundant_foreign_key.rb +1 -1
  31. data/lib/rubocop/cop/rails/redundant_presence_validation_on_belongs_to.rb +9 -0
  32. data/lib/rubocop/cop/rails/redundant_receiver_in_with_options.rb +1 -1
  33. data/lib/rubocop/cop/rails/reflection_class_name.rb +1 -1
  34. data/lib/rubocop/cop/rails/render_plain_text.rb +6 -3
  35. data/lib/rubocop/cop/rails/request_referer.rb +1 -1
  36. data/lib/rubocop/cop/rails/reversible_migration.rb +1 -1
  37. data/lib/rubocop/cop/rails/root_pathname_methods.rb +15 -11
  38. data/lib/rubocop/cop/rails/save_bang.rb +2 -0
  39. data/lib/rubocop/cop/rails/skips_model_validations.rb +8 -3
  40. data/lib/rubocop/cop/rails/time_zone.rb +2 -1
  41. data/lib/rubocop/cop/rails/uniq_before_pluck.rb +12 -4
  42. data/lib/rubocop/cop/rails/unknown_env.rb +1 -1
  43. data/lib/rubocop/cop/rails/unused_ignored_columns.rb +6 -0
  44. data/lib/rubocop/cop/rails/validation.rb +8 -3
  45. data/lib/rubocop/cop/rails/where_equals.rb +28 -12
  46. data/lib/rubocop/cop/rails/where_exists.rb +3 -3
  47. data/lib/rubocop/cop/rails/where_missing.rb +5 -1
  48. data/lib/rubocop/cop/rails/where_not.rb +11 -6
  49. data/lib/rubocop/cop/rails/where_range.rb +203 -0
  50. data/lib/rubocop/cop/rails_cops.rb +2 -0
  51. data/lib/rubocop/rails/schema_loader/schema.rb +2 -1
  52. data/lib/rubocop/rails/schema_loader.rb +5 -15
  53. data/lib/rubocop/rails/version.rb +1 -1
  54. metadata +10 -8
@@ -53,9 +53,12 @@ module RuboCop
53
53
  node.pairs.find { |p| p.key.value.to_sym == :content_type }
54
54
  end
55
55
 
56
- def compatible_content_type?(node)
57
- (node && node.value.value == 'text/plain') ||
58
- (!node && !cop_config['ContentTypeCompatibility'])
56
+ def compatible_content_type?(pair_node)
57
+ if pair_node.nil?
58
+ !cop_config['ContentTypeCompatibility']
59
+ elsif pair_node.value.respond_to?(:value)
60
+ pair_node.value.value == 'text/plain'
61
+ end
59
62
  end
60
63
 
61
64
  def replacement(rest_options, option_value)
@@ -34,7 +34,7 @@ module RuboCop
34
34
  referer?(node) do
35
35
  return unless node.method?(wrong_method_name)
36
36
 
37
- add_offense(node.source_range) do |corrector|
37
+ add_offense(node) do |corrector|
38
38
  corrector.replace(node, "request.#{style}")
39
39
  end
40
40
  end
@@ -17,7 +17,7 @@ module RuboCop
17
17
  # # good
18
18
  # def change
19
19
  # change_table :users do |t|
20
- # t.remove :name, :string
20
+ # t.remove :name, type: :string
21
21
  # end
22
22
  # end
23
23
  #
@@ -23,6 +23,8 @@ module RuboCop
23
23
  # File.binread(Rails.root.join('db', 'schema.rb'))
24
24
  # File.write(Rails.root.join('db', 'schema.rb'), content)
25
25
  # File.binwrite(Rails.root.join('db', 'schema.rb'), content)
26
+ # Dir.glob(Rails.root.join('db', 'schema.rb'))
27
+ # Dir[Rails.root.join('db', 'schema.rb')]
26
28
  #
27
29
  # # good
28
30
  # Rails.root.join('db', 'schema.rb').open
@@ -31,14 +33,15 @@ module RuboCop
31
33
  # Rails.root.join('db', 'schema.rb').binread
32
34
  # Rails.root.join('db', 'schema.rb').write(content)
33
35
  # Rails.root.join('db', 'schema.rb').binwrite(content)
36
+ # Rails.root.glob("db/schema.rb")
34
37
  #
35
38
  class RootPathnameMethods < Base # rubocop:disable Metrics/ClassLength
36
39
  extend AutoCorrector
37
40
  include RangeHelp
38
41
 
39
- MSG = '`%<rails_root>s` is a `Pathname` so you can just append `#%<method>s`.'
42
+ MSG = '`%<rails_root>s` is a `Pathname`, so you can use `%<replacement>s`.'
40
43
 
41
- DIR_GLOB_METHODS = %i[glob].to_set.freeze
44
+ DIR_GLOB_METHODS = %i[[] glob].to_set.freeze
42
45
 
43
46
  DIR_NON_GLOB_METHODS = %i[
44
47
  children
@@ -171,7 +174,7 @@ module RuboCop
171
174
 
172
175
  def_node_matcher :dir_glob?, <<~PATTERN
173
176
  (send
174
- (const {cbase nil?} :Dir) :glob ...)
177
+ (const {cbase nil?} :Dir) DIR_GLOB_METHODS ...)
175
178
  PATTERN
176
179
 
177
180
  def_node_matcher :rails_root_pathname?, <<~PATTERN
@@ -188,13 +191,14 @@ module RuboCop
188
191
 
189
192
  def on_send(node)
190
193
  evidence(node) do |method, path, args, rails_root|
191
- add_offense(node, message: format(MSG, method: method, rails_root: rails_root.source)) do |corrector|
192
- replacement = if dir_glob?(node)
193
- build_path_glob_replacement(path, method)
194
- else
195
- build_path_replacement(path, method, args)
196
- end
194
+ replacement = if dir_glob?(node)
195
+ build_path_glob_replacement(path)
196
+ else
197
+ build_path_replacement(path, method, args)
198
+ end
197
199
 
200
+ message = format(MSG, rails_root: rails_root.source, replacement: replacement)
201
+ add_offense(node, message: message) do |corrector|
198
202
  corrector.replace(node, replacement)
199
203
  end
200
204
  end
@@ -217,12 +221,12 @@ module RuboCop
217
221
  end
218
222
  end
219
223
 
220
- def build_path_glob_replacement(path, method)
224
+ def build_path_glob_replacement(path)
221
225
  receiver = range_between(path.source_range.begin_pos, path.children.first.loc.selector.end_pos).source
222
226
 
223
227
  argument = path.arguments.one? ? path.first_argument.source : join_arguments(path.arguments)
224
228
 
225
- "#{receiver}.#{method}(#{argument})"
229
+ "#{receiver}.glob(#{argument})"
226
230
  end
227
231
 
228
232
  def build_path_replacement(path, method, args)
@@ -196,6 +196,8 @@ module RuboCop
196
196
  end
197
197
 
198
198
  def call_to_persisted?(node)
199
+ node = node.parent.condition if node.parenthesized_call? && node.parent.if_type?
200
+
199
201
  node.send_type? && node.method?(:persisted?)
200
202
  end
201
203
 
@@ -9,6 +9,9 @@ module RuboCop
9
9
  #
10
10
  # Methods may be ignored from this rule by configuring a `AllowedMethods`.
11
11
  #
12
+ # @safety
13
+ # This cop is unsafe if the receiver object is not an Active Record object.
14
+ #
12
15
  # @example
13
16
  # # bad
14
17
  # Article.first.decrement!(:view_count)
@@ -63,7 +66,7 @@ module RuboCop
63
66
  PATTERN
64
67
 
65
68
  def_node_matcher :good_insert?, <<~PATTERN
66
- (send _ {:insert :insert!} _ {
69
+ (call _ {:insert :insert!} _ {
67
70
  !(hash ...)
68
71
  (hash <(pair (sym !{:returning :unique_by}) _) ...>)
69
72
  } ...)
@@ -97,7 +100,8 @@ module RuboCop
97
100
  end
98
101
 
99
102
  def forbidden_methods
100
- obsolete_result = cop_config['Blacklist']
103
+ # TODO: Remove when RuboCop Rails 3 releases.
104
+ obsolete_result = cop_config['Blacklist'] # rubocop:disable InternalAffairs/UndefinedConfig
101
105
  if obsolete_result
102
106
  warn '`Blacklist` has been renamed to `ForbiddenMethods`.' unless @displayed_forbidden_warning
103
107
  @displayed_forbidden_warning = true
@@ -108,7 +112,8 @@ module RuboCop
108
112
  end
109
113
 
110
114
  def allowed_methods
111
- obsolete_result = cop_config['Whitelist']
115
+ # TODO: Remove when RuboCop Rails 3 releases.
116
+ obsolete_result = cop_config['Whitelist'] # rubocop:disable InternalAffairs/UndefinedConfig
112
117
  if obsolete_result
113
118
  warn '`Whitelist` has been renamed to `AllowedMethods`.' unless @displayed_allowed_warning
114
119
  @displayed_allowed_warning = true
@@ -69,9 +69,10 @@ module RuboCop
69
69
  return if !node.receiver&.str_type? || !node.method?(:to_time)
70
70
 
71
71
  add_offense(node.loc.selector, message: MSG_STRING_TO_TIME) do |corrector|
72
- corrector.replace(node, "Time.zone.parse(#{node.receiver.source})")
72
+ corrector.replace(node, "Time.zone.parse(#{node.receiver.source})") unless node.csend_type?
73
73
  end
74
74
  end
75
+ alias on_csend on_send
75
76
 
76
77
  private
77
78
 
@@ -68,15 +68,23 @@ module RuboCop
68
68
  return unless uniq
69
69
 
70
70
  add_offense(node.loc.selector) do |corrector|
71
- method = node.method_name
72
-
73
- corrector.remove(dot_method_with_whitespace(method, node))
74
- corrector.insert_before(node.receiver.loc.dot.begin, '.distinct')
71
+ autocorrect(corrector, node)
75
72
  end
76
73
  end
77
74
 
78
75
  private
79
76
 
77
+ def autocorrect(corrector, node)
78
+ method = node.method_name
79
+
80
+ corrector.remove(dot_method_with_whitespace(method, node))
81
+ if (dot = node.receiver.loc.dot)
82
+ corrector.insert_before(dot.begin, '.distinct')
83
+ else
84
+ corrector.insert_before(node.receiver, 'distinct.')
85
+ end
86
+ end
87
+
80
88
  def dot_method_with_whitespace(method, node)
81
89
  range_between(dot_method_begin_pos(method, node), node.loc.selector.end_pos)
82
90
  end
@@ -87,7 +87,7 @@ module RuboCop
87
87
 
88
88
  def environments
89
89
  @environments ||= begin
90
- environments = cop_config['Environments'] || []
90
+ environments = cop_config['Environments'].dup || []
91
91
  environments << 'local' if target_rails_version >= 7.1
92
92
  environments
93
93
  end
@@ -7,6 +7,12 @@ module RuboCop
7
7
  # `ignored_columns` is necessary to drop a column from RDBMS, but you don't need it after the migration
8
8
  # to drop the column. You avoid forgetting to remove `ignored_columns` by this cop.
9
9
  #
10
+ # IMPORTANT: This cop can't be used to effectively check for unused columns because the development
11
+ # and production schema can be out of sync until the migration has been run on production. As such,
12
+ # this cop can cause `ignored_columns` to be removed even though the production schema still contains
13
+ # the column, which can lead to downtime when the migration is actually executed. Only enable this cop
14
+ # if you know your migrations will be run before any of your Rails applications boot with the modified code.
15
+ #
10
16
  # @example
11
17
  # # bad
12
18
  # class User < ApplicationRecord
@@ -8,6 +8,7 @@ module RuboCop
8
8
  # @example
9
9
  # # bad
10
10
  # validates_acceptance_of :foo
11
+ # validates_comparison_of :foo
11
12
  # validates_confirmation_of :foo
12
13
  # validates_exclusion_of :foo
13
14
  # validates_format_of :foo
@@ -22,6 +23,7 @@ module RuboCop
22
23
  # # good
23
24
  # validates :foo, acceptance: true
24
25
  # validates :foo, confirmation: true
26
+ # validates :foo, comparison: true
25
27
  # validates :foo, exclusion: true
26
28
  # validates :foo, format: true
27
29
  # validates :foo, inclusion: true
@@ -29,7 +31,7 @@ module RuboCop
29
31
  # validates :foo, numericality: true
30
32
  # validates :foo, presence: true
31
33
  # validates :foo, absence: true
32
- # validates :foo, size: true
34
+ # validates :foo, length: true
33
35
  # validates :foo, uniqueness: true
34
36
  #
35
37
  class Validation < Base
@@ -39,6 +41,7 @@ module RuboCop
39
41
 
40
42
  TYPES = %w[
41
43
  acceptance
44
+ comparison
42
45
  confirmation
43
46
  exclusion
44
47
  format
@@ -56,11 +59,11 @@ module RuboCop
56
59
 
57
60
  def on_send(node)
58
61
  return if node.receiver
62
+ return unless (last_argument = node.last_argument)
59
63
 
60
64
  range = node.loc.selector
61
65
 
62
66
  add_offense(range, message: message(node)) do |corrector|
63
- last_argument = node.last_argument
64
67
  return if !last_argument.literal? && !last_argument.splat_type? && !frozen_array_argument?(last_argument)
65
68
 
66
69
  corrector.replace(range, 'validates')
@@ -120,7 +123,9 @@ module RuboCop
120
123
  end
121
124
 
122
125
  def validate_type(node)
123
- node.method_name.to_s.split('_')[1]
126
+ type = node.method_name.to_s.split('_')[1]
127
+
128
+ type == 'size' ? 'length' : type
124
129
  end
125
130
 
126
131
  def frozen_array_argument?(argument)
@@ -4,7 +4,8 @@ module RuboCop
4
4
  module Cop
5
5
  module Rails
6
6
  # Identifies places where manually constructed SQL
7
- # in `where` can be replaced with `where(attribute: value)`.
7
+ # in `where` and `where.not` can be replaced with
8
+ # `where(attribute: value)` and `where.not(attribute: value)`.
8
9
  #
9
10
  # @safety
10
11
  # This cop's autocorrection is unsafe because is may change SQL.
@@ -13,6 +14,7 @@ module RuboCop
13
14
  # @example
14
15
  # # bad
15
16
  # User.where('name = ?', 'Gabe')
17
+ # User.where.not('name = ?', 'Gabe')
16
18
  # User.where('name = :name', name: 'Gabe')
17
19
  # User.where('name IS NULL')
18
20
  # User.where('name IN (?)', ['john', 'jane'])
@@ -21,6 +23,7 @@ module RuboCop
21
23
  #
22
24
  # # good
23
25
  # User.where(name: 'Gabe')
26
+ # User.where.not(name: 'Gabe')
24
27
  # User.where(name: nil)
25
28
  # User.where(name: ['john', 'jane'])
26
29
  # User.where(users: { name: 'Gabe' })
@@ -29,25 +32,27 @@ module RuboCop
29
32
  extend AutoCorrector
30
33
 
31
34
  MSG = 'Use `%<good_method>s` instead of manually constructing SQL.'
32
- RESTRICT_ON_SEND = %i[where].freeze
35
+ RESTRICT_ON_SEND = %i[where not].freeze
33
36
 
34
37
  def_node_matcher :where_method_call?, <<~PATTERN
35
38
  {
36
- (call _ :where (array $str_type? $_ ?))
37
- (call _ :where $str_type? $_ ?)
39
+ (call _ {:where :not} (array $str_type? $_ ?))
40
+ (call _ {:where :not} $str_type? $_ ?)
38
41
  }
39
42
  PATTERN
40
43
 
41
44
  def on_send(node)
45
+ return if node.method?(:not) && !where_not?(node)
46
+
42
47
  where_method_call?(node) do |template_node, value_node|
43
48
  value_node = value_node.first
44
49
 
45
50
  range = offense_range(node)
46
51
 
47
- column_and_value = extract_column_and_value(template_node, value_node)
48
- return unless column_and_value
52
+ column, value = extract_column_and_value(template_node, value_node)
53
+ return unless value
49
54
 
50
- good_method = build_good_method(*column_and_value)
55
+ good_method = build_good_method(node.method_name, column, value)
51
56
  message = format(MSG, good_method: good_method)
52
57
 
53
58
  add_offense(range, message: message) do |corrector|
@@ -69,11 +74,12 @@ module RuboCop
69
74
  range_between(node.loc.selector.begin_pos, node.source_range.end_pos)
70
75
  end
71
76
 
77
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
72
78
  def extract_column_and_value(template_node, value_node)
73
79
  value =
74
80
  case template_node.value
75
81
  when EQ_ANONYMOUS_RE, IN_ANONYMOUS_RE
76
- value_node.source
82
+ value_node&.source
77
83
  when EQ_NAMED_RE, IN_NAMED_RE
78
84
  return unless value_node&.hash_type?
79
85
 
@@ -85,18 +91,28 @@ module RuboCop
85
91
  return
86
92
  end
87
93
 
88
- [Regexp.last_match(1), value]
94
+ column_qualifier = Regexp.last_match(1)
95
+ return if column_qualifier.count('.') > 1
96
+
97
+ [column_qualifier, value]
89
98
  end
99
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
90
100
 
91
- def build_good_method(column, value)
101
+ def build_good_method(method_name, column, value)
92
102
  if column.include?('.')
93
103
  table, column = column.split('.')
94
104
 
95
- "where(#{table}: { #{column}: #{value} })"
105
+ "#{method_name}(#{table}: { #{column}: #{value} })"
96
106
  else
97
- "where(#{column}: #{value})"
107
+ "#{method_name}(#{column}: #{value})"
98
108
  end
99
109
  end
110
+
111
+ def where_not?(node)
112
+ return false unless (receiver = node.receiver)
113
+
114
+ receiver.send_type? && receiver.method?(:where)
115
+ end
100
116
  end
101
117
  end
102
118
  end
@@ -67,7 +67,7 @@ module RuboCop
67
67
  return unless convertable_args?(args)
68
68
 
69
69
  range = correction_range(node)
70
- good_method = build_good_method(args, dot_source: node.loc.dot.source)
70
+ good_method = build_good_method(args, dot: node.loc.dot)
71
71
  message = format(MSG, good_method: good_method, bad_method: range.source)
72
72
 
73
73
  add_offense(range, message: message) do |corrector|
@@ -109,11 +109,11 @@ module RuboCop
109
109
  end
110
110
  end
111
111
 
112
- def build_good_method(args, dot_source: '.')
112
+ def build_good_method(args, dot:)
113
113
  if exists_style?
114
114
  build_good_method_exists(args)
115
115
  elsif where_style?
116
- build_good_method_where(args, dot_source)
116
+ build_good_method_where(args, dot&.source || '.')
117
117
  end
118
118
  end
119
119
 
@@ -89,16 +89,20 @@ module RuboCop
89
89
  end
90
90
  end
91
91
 
92
+ # rubocop:disable Metrics/AbcSize
92
93
  def remove_where_method(corrector, node, where_node)
93
94
  range = range_between(where_node.loc.selector.begin_pos, where_node.loc.end.end_pos)
94
95
  if node.multiline? && !same_line?(node, where_node)
95
96
  range = range_by_whole_lines(range, include_final_newline: true)
96
- else
97
+ elsif where_node.receiver
97
98
  corrector.remove(where_node.loc.dot)
99
+ else
100
+ corrector.remove(node.loc.dot)
98
101
  end
99
102
 
100
103
  corrector.remove(range)
101
104
  end
105
+ # rubocop:enable Metrics/AbcSize
102
106
 
103
107
  def same_line?(left_joins_node, where_node)
104
108
  left_joins_node.loc.selector.line == where_node.loc.selector.line
@@ -43,10 +43,10 @@ module RuboCop
43
43
 
44
44
  range = offense_range(node)
45
45
 
46
- column_and_value = extract_column_and_value(template_node, value_node)
47
- return unless column_and_value
46
+ column, value = extract_column_and_value(template_node, value_node)
47
+ return unless value
48
48
 
49
- good_method = build_good_method(node.loc.dot&.source, *column_and_value)
49
+ good_method = build_good_method(node.loc.dot&.source, column, value)
50
50
  message = format(MSG, good_method: good_method)
51
51
 
52
52
  add_offense(range, message: message) do |corrector|
@@ -68,13 +68,14 @@ module RuboCop
68
68
  range_between(node.loc.selector.begin_pos, node.source_range.end_pos)
69
69
  end
70
70
 
71
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
71
72
  def extract_column_and_value(template_node, value_node)
72
73
  value =
73
74
  case template_node.value
74
75
  when NOT_EQ_ANONYMOUS_RE, NOT_IN_ANONYMOUS_RE
75
- value_node.source
76
+ value_node&.source
76
77
  when NOT_EQ_NAMED_RE, NOT_IN_NAMED_RE
77
- return unless value_node.hash_type?
78
+ return unless value_node&.hash_type?
78
79
 
79
80
  pair = value_node.pairs.find { |p| p.key.value.to_sym == Regexp.last_match(2).to_sym }
80
81
  pair.value.source
@@ -84,8 +85,12 @@ module RuboCop
84
85
  return
85
86
  end
86
87
 
87
- [Regexp.last_match(1), value]
88
+ column_qualifier = Regexp.last_match(1)
89
+ return if column_qualifier.count('.') > 1
90
+
91
+ [column_qualifier, value]
88
92
  end
93
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
89
94
 
90
95
  def build_good_method(dot, column, value)
91
96
  dot ||= '.'
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Rails
6
+ # Identifies places where manually constructed SQL
7
+ # in `where` can be replaced with ranges.
8
+ #
9
+ # @safety
10
+ # This cop's autocorrection is unsafe because it can change the query
11
+ # by explicitly attaching the column to the wrong table.
12
+ # For example, `Booking.joins(:events).where('end_at < ?', Time.current)` will correctly
13
+ # implicitly attach the `end_at` column to the `events` table. But when autocorrected to
14
+ # `Booking.joins(:events).where(end_at: ...Time.current)`, it will now be incorrectly
15
+ # explicitly attached to the `bookings` table.
16
+ #
17
+ # @example
18
+ # # bad
19
+ # User.where('age >= ?', 18)
20
+ # User.where.not('age >= ?', 18)
21
+ # User.where('age < ?', 18)
22
+ # User.where('age >= ? AND age < ?', 18, 21)
23
+ # User.where('age >= :start', start: 18)
24
+ # User.where('users.age >= ?', 18)
25
+ #
26
+ # # good
27
+ # User.where(age: 18..)
28
+ # User.where.not(age: 18..)
29
+ # User.where(age: ...18)
30
+ # User.where(age: 18...21)
31
+ # User.where(users: { age: 18.. })
32
+ #
33
+ # # good
34
+ # # There are no beginless ranges in ruby.
35
+ # User.where('age > ?', 18)
36
+ #
37
+ class WhereRange < Base
38
+ include RangeHelp
39
+ extend AutoCorrector
40
+ extend TargetRubyVersion
41
+ extend TargetRailsVersion
42
+
43
+ MSG = 'Use `%<good_method>s` instead of manually constructing SQL.'
44
+
45
+ RESTRICT_ON_SEND = %i[where not].freeze
46
+
47
+ # column >= ?
48
+ GTEQ_ANONYMOUS_RE = /\A\s*([\w.]+)\s+>=\s+\?\s*\z/.freeze
49
+ # column <[=] ?
50
+ LTEQ_ANONYMOUS_RE = /\A\s*([\w.]+)\s+(<=?)\s+\?\s*\z/.freeze
51
+ # column >= ? AND column <[=] ?
52
+ RANGE_ANONYMOUS_RE = /\A\s*([\w.]+)\s+>=\s+\?\s+AND\s+\1\s+(<=?)\s+\?\s*\z/i.freeze
53
+ # column >= :value
54
+ GTEQ_NAMED_RE = /\A\s*([\w.]+)\s+>=\s+:(\w+)\s*\z/.freeze
55
+ # column <[=] :value
56
+ LTEQ_NAMED_RE = /\A\s*([\w.]+)\s+(<=?)\s+:(\w+)\s*\z/.freeze
57
+ # column >= :value1 AND column <[=] :value2
58
+ RANGE_NAMED_RE = /\A\s*([\w.]+)\s+>=\s+:(\w+)\s+AND\s+\1\s+(<=?)\s+:(\w+)\s*\z/i.freeze
59
+
60
+ minimum_target_ruby_version 2.6
61
+ minimum_target_rails_version 6.0
62
+
63
+ def_node_matcher :where_range_call?, <<~PATTERN
64
+ {
65
+ (call _ {:where :not} (array $str_type? $_ +))
66
+ (call _ {:where :not} $str_type? $_ +)
67
+ }
68
+ PATTERN
69
+
70
+ def on_send(node)
71
+ return if node.method?(:not) && !where_not?(node)
72
+
73
+ where_range_call?(node) do |template_node, values_node|
74
+ column, value = extract_column_and_value(template_node, values_node)
75
+
76
+ return unless column
77
+
78
+ range = offense_range(node)
79
+ good_method = build_good_method(node.method_name, column, value)
80
+ message = format(MSG, good_method: good_method)
81
+
82
+ add_offense(range, message: message) do |corrector|
83
+ corrector.replace(range, good_method)
84
+ end
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def where_not?(node)
91
+ receiver = node.receiver
92
+ receiver&.send_type? && receiver&.method?(:where)
93
+ end
94
+
95
+ # rubocop:disable Metrics
96
+ def extract_column_and_value(template_node, values_node)
97
+ case template_node.value
98
+ when GTEQ_ANONYMOUS_RE
99
+ lhs = values_node[0]
100
+ operator = '..'
101
+ when LTEQ_ANONYMOUS_RE
102
+ if target_ruby_version >= 2.7
103
+ operator = range_operator(Regexp.last_match(2))
104
+ rhs = values_node[0]
105
+ end
106
+ when RANGE_ANONYMOUS_RE
107
+ if values_node.size >= 2
108
+ lhs = values_node[0]
109
+ operator = range_operator(Regexp.last_match(2))
110
+ rhs = values_node[1]
111
+ end
112
+ when GTEQ_NAMED_RE
113
+ value_node = values_node[0]
114
+
115
+ if value_node.hash_type?
116
+ pair = find_pair(value_node, Regexp.last_match(2))
117
+ lhs = pair.value
118
+ operator = '..'
119
+ end
120
+ when LTEQ_NAMED_RE
121
+ value_node = values_node[0]
122
+
123
+ if value_node.hash_type?
124
+ pair = find_pair(value_node, Regexp.last_match(2))
125
+ if pair && target_ruby_version >= 2.7
126
+ operator = range_operator(Regexp.last_match(2))
127
+ rhs = pair.value
128
+ end
129
+ end
130
+ when RANGE_NAMED_RE
131
+ value_node = values_node[0]
132
+
133
+ if value_node.hash_type?
134
+ pair1 = find_pair(value_node, Regexp.last_match(2))
135
+ pair2 = find_pair(value_node, Regexp.last_match(4))
136
+
137
+ if pair1 && pair2
138
+ lhs = pair1.value
139
+ operator = range_operator(Regexp.last_match(3))
140
+ rhs = pair2.value
141
+ end
142
+ end
143
+ else
144
+ return
145
+ end
146
+
147
+ if lhs
148
+ lhs_source = parentheses_needed?(lhs) ? "(#{lhs.source})" : lhs.source
149
+ end
150
+
151
+ if rhs
152
+ rhs_source = parentheses_needed?(rhs) ? "(#{rhs.source})" : rhs.source
153
+ end
154
+
155
+ column_qualifier = Regexp.last_match(1)
156
+ return if column_qualifier.count('.') > 1
157
+
158
+ [column_qualifier, "#{lhs_source}#{operator}#{rhs_source}"] if operator
159
+ end
160
+ # rubocop:enable Metrics
161
+
162
+ def range_operator(comparison_operator)
163
+ comparison_operator == '<' ? '...' : '..'
164
+ end
165
+
166
+ def find_pair(hash_node, value)
167
+ hash_node.pairs.find { |pair| pair.key.value.to_sym == value.to_sym }
168
+ end
169
+
170
+ def offense_range(node)
171
+ range_between(node.loc.selector.begin_pos, node.source_range.end_pos)
172
+ end
173
+
174
+ def build_good_method(method_name, column, value)
175
+ if column.include?('.')
176
+ table, column = column.split('.')
177
+
178
+ "#{method_name}(#{table}: { #{column}: #{value} })"
179
+ else
180
+ "#{method_name}(#{column}: #{value})"
181
+ end
182
+ end
183
+
184
+ def parentheses_needed?(node)
185
+ !parentheses_not_needed?(node)
186
+ end
187
+
188
+ def parentheses_not_needed?(node)
189
+ node.variable? ||
190
+ node.literal? ||
191
+ node.reference? ||
192
+ node.const_type? ||
193
+ node.begin_type? ||
194
+ parenthesized_call_node?(node)
195
+ end
196
+
197
+ def parenthesized_call_node?(node)
198
+ node.call_type? && (node.arguments.empty? || node.parenthesized_call?)
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end