Skip to content

Commit a56a4f3

Browse files
Add InOrderOf cop
1 parent 2b228a8 commit a56a4f3

File tree

5 files changed

+245
-0
lines changed

5 files changed

+245
-0
lines changed

changelog/new_in_order_of_cop.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* [#1554](https://github.com/rubocop/rubocop-rails/pull/1554): Add new `Rails/InOrderOf` cop to suggest using `in_order_of` for ordering instead of manually constructing SQL. ([@davidenglishmusic][])

config/default.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,12 @@ Rails/IgnoredSkipActionFilterOption:
665665
- '**/app/controllers/**/*.rb'
666666
- '**/app/mailers/**/*.rb'
667667

668+
Rails/InOrderOf:
669+
Description: 'Use `in_order_of` for ordering instead of manually constructing SQL.'
670+
Reference: 'https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-in_order_of'
671+
Enabled: pending
672+
VersionAdded: '<<next>>'
673+
668674
Rails/IndexBy:
669675
Description: 'Prefer `index_by` over `each_with_object`, `to_h`, or `map`.'
670676
Enabled: true
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# frozen_string_literal: true
2+
3+
module RuboCop
4+
module Cop
5+
module Rails
6+
# Checks for manual construction of ordering logic and suggests using `in_order_of` instead.
7+
#
8+
# `in_order_of` was introduced in Rails 7.0 to allow custom ordering of records
9+
# without using raw SQL with `CASE` statements or `Arel.sql`.
10+
#
11+
# @example
12+
# # bad
13+
# Post.order(
14+
# Arel.sql(
15+
# <<~SQL
16+
# case status
17+
# when 'draft' then 1
18+
# when 'published' then 2
19+
# when 'archived' then 3
20+
# else 4 end
21+
# SQL
22+
# )
23+
# )
24+
#
25+
# # good
26+
# Post.in_order_of(:status, %w[draft published archived])
27+
#
28+
# # bad - using MySQL FIELD function
29+
# Post.order(
30+
# Arel.sql("FIELD(status, 'draft', 'published', 'archived')")
31+
# )
32+
#
33+
# # good
34+
# Post.in_order_of(:status, %w[draft published archived])
35+
#
36+
# # bad - with string interpolation
37+
# Post.order(Arel.sql("FIELD(status, '#{statuses.join("', '")}')"))
38+
#
39+
# # good
40+
# Post.in_order_of(:status, statuses)
41+
#
42+
class InOrderOf < Base
43+
extend TargetRailsVersion
44+
45+
minimum_target_rails_version 7.0
46+
47+
MSG = 'Use `in_order_of` for ordering instead of manually constructing SQL.'
48+
RESTRICT_ON_SEND = %i[order].freeze
49+
50+
# Matches: case column when ... or case #{var} when ...
51+
CASE_WHEN_PATTERN = /\bcase\s+(\w+|#\{[^}]+\})\s+when\b/im.freeze
52+
# Matches: FIELD(column, ...)
53+
FIELD_FUNCTION_PATTERN = /\bFIELD\s*\(/i.freeze
54+
55+
AREL_CONST_NAME = 'Arel'
56+
HEREDOC_START = '<<'
57+
58+
def on_send(node)
59+
return unless node.method?(:order)
60+
return unless (sql_string = extract_sql_string(node))
61+
return unless custom_order_sql?(sql_string)
62+
63+
add_offense(node.loc.selector)
64+
end
65+
66+
private
67+
68+
def extract_sql_string(node)
69+
sql_arg = sql_argument(node)
70+
extract_string_content(sql_arg) if sql_arg
71+
end
72+
73+
def sql_argument(node)
74+
arg = node.first_argument
75+
return unless arg&.send_type? && arg.method?(:sql)
76+
return unless arel_const?(arg.receiver)
77+
78+
arg.first_argument if arg.first_argument&.type?(:str, :dstr)
79+
end
80+
81+
def extract_string_content(sql_arg)
82+
case sql_arg.type
83+
when :str then sql_arg.str_content
84+
when :dstr then extract_dstr_content(sql_arg)
85+
end
86+
end
87+
88+
def extract_dstr_content(sql_arg)
89+
source = sql_arg.source
90+
source.start_with?(HEREDOC_START) ? heredoc_content(sql_arg) : source[1...-1]
91+
end
92+
93+
def heredoc_content(sql_arg)
94+
sql_arg.each_child_node(:str).map(&:str_content).join
95+
end
96+
97+
def arel_const?(node)
98+
node&.const_type? && node.const_name == AREL_CONST_NAME
99+
end
100+
101+
def custom_order_sql?(sql_string)
102+
sql_string.match?(CASE_WHEN_PATTERN) || sql_string.match?(FIELD_FUNCTION_PATTERN)
103+
end
104+
end
105+
end
106+
end
107+
end

lib/rubocop/cop/rails_cops.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
require_relative 'rails/i18n_locale_texts'
7474
require_relative 'rails/ignored_columns_assignment'
7575
require_relative 'rails/ignored_skip_action_filter_option'
76+
require_relative 'rails/in_order_of'
7677
require_relative 'rails/index_by'
7778
require_relative 'rails/index_with'
7879
require_relative 'rails/inquiry'
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe RuboCop::Cop::Rails::InOrderOf, :config do
4+
context 'Rails >= 7.0', :rails70 do
5+
context 'when using Arel.sql with CASE statement' do
6+
it 'registers an offense' do
7+
expect_offense(<<~RUBY)
8+
Post.order(
9+
^^^^^ Use `in_order_of` for ordering instead of manually constructing SQL.
10+
Arel.sql(
11+
<<~SQL
12+
case status
13+
when 'draft' then 1
14+
when 'published' then 2
15+
when 'archived' then 3
16+
else 4 end
17+
SQL
18+
)
19+
)
20+
RUBY
21+
end
22+
end
23+
24+
context 'when using Arel.sql with inline CASE statement' do
25+
it 'registers an offense' do
26+
expect_offense(<<~RUBY)
27+
User.order(Arel.sql("CASE status WHEN 'active' THEN 1 ELSE 2 END"))
28+
^^^^^ Use `in_order_of` for ordering instead of manually constructing SQL.
29+
RUBY
30+
end
31+
end
32+
33+
context 'when using Arel.sql with FIELD function' do
34+
it 'registers an offense' do
35+
expect_offense(<<~RUBY)
36+
Post.order(Arel.sql("FIELD(status, 'draft', 'published', 'archived')"))
37+
^^^^^ Use `in_order_of` for ordering instead of manually constructing SQL.
38+
RUBY
39+
end
40+
end
41+
42+
context 'when using ::Arel.sql with CASE statement' do
43+
it 'registers an offense' do
44+
expect_offense(<<~RUBY)
45+
Post.order(::Arel.sql("case priority when 'high' then 1 else 2 end"))
46+
^^^^^ Use `in_order_of` for ordering instead of manually constructing SQL.
47+
RUBY
48+
end
49+
end
50+
51+
context 'when using Arel.sql without CASE or FIELD' do
52+
it 'does not register an offense' do
53+
expect_no_offenses(<<~RUBY)
54+
User.order(Arel.sql("created_at DESC"))
55+
RUBY
56+
end
57+
end
58+
59+
context 'when using regular order methods' do
60+
it 'does not register an offense with symbol' do
61+
expect_no_offenses(<<~RUBY)
62+
User.order(:created_at)
63+
RUBY
64+
end
65+
66+
it 'does not register an offense with hash' do
67+
expect_no_offenses(<<~RUBY)
68+
User.order(created_at: :desc)
69+
RUBY
70+
end
71+
72+
it 'does not register an offense with string' do
73+
expect_no_offenses(<<~RUBY)
74+
User.order('created_at DESC')
75+
RUBY
76+
end
77+
end
78+
79+
context 'when using in_order_of' do
80+
it 'does not register an offense' do
81+
expect_no_offenses(<<~RUBY)
82+
Post.in_order_of(:status, %w[draft published archived])
83+
RUBY
84+
end
85+
end
86+
87+
context 'when using Arel.sql with FIELD and string interpolation' do
88+
it 'registers an offense' do
89+
expect_offense(<<~'RUBY')
90+
TITLES = ['foo', 'bar']
91+
Post.order(Arel.sql("FIELD(title,'#{TITLES.join(",")}')"))
92+
^^^^^ Use `in_order_of` for ordering instead of manually constructing SQL.
93+
RUBY
94+
end
95+
96+
it 'registers an offense with join using single quotes' do
97+
expect_offense(<<~'RUBY')
98+
Post.order(Arel.sql("FIELD(status,'#{UNPUBLISHED_STATES.join("','")}')"))
99+
^^^^^ Use `in_order_of` for ordering instead of manually constructing SQL.
100+
RUBY
101+
end
102+
103+
it 'registers an offense with exact user pattern' do
104+
expect_offense(<<~'RUBY')
105+
TITLES = ['foo', 'bar']
106+
Post.order(Arel.sql("FIELD(title,'#{TITLES.join("','")}')"))
107+
^^^^^ Use `in_order_of` for ordering instead of manually constructing SQL.
108+
RUBY
109+
end
110+
end
111+
112+
context 'when using Arel.sql with CASE and string interpolation' do
113+
it 'registers an offense' do
114+
expect_offense(<<~'RUBY')
115+
column = 'status'
116+
User.order(Arel.sql("CASE #{column} WHEN 'active' THEN 1 ELSE 2 END"))
117+
^^^^^ Use `in_order_of` for ordering instead of manually constructing SQL.
118+
RUBY
119+
end
120+
end
121+
end
122+
123+
context 'Rails <= 6.1', :rails61 do
124+
it 'does not register an offense for Arel.sql with CASE' do
125+
expect_no_offenses(<<~RUBY)
126+
Post.order(Arel.sql("case status when 'draft' then 1 else 2 end"))
127+
RUBY
128+
end
129+
end
130+
end

0 commit comments

Comments
 (0)