rubocop-dev_doc 0.5.0.beta1 → 0.6.0.beta1
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.
- checksums.yaml +4 -4
- data/config/default.yml +74 -43
- data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +47 -4
- data/lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb +59 -0
- data/lib/rubocop/cop/dev_doc/i18n/localizable_props.rb +109 -0
- data/lib/rubocop/cop/dev_doc/i18n/report_text.rb +8 -44
- data/lib/rubocop/cop/dev_doc/i18n/require_translation.rb +21 -42
- data/lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb +53 -2
- data/lib/rubocop/cop/dev_doc/migration/avoid_vague_column_names.rb +7 -1
- data/lib/rubocop/cop/dev_doc/rails/avoid_ordering_by_id.rb +167 -0
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +14 -4
- data/lib/rubocop/cop/dev_doc/rails/avoid_raw_sql.rb +227 -0
- data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +86 -0
- data/lib/rubocop/cop/dev_doc/style/tap_block_ignores_value.rb +123 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +24 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +7 -2
- data/lib/rubocop/cop/dev_doc/i18n/unverified_translation.rb +0 -106
|
@@ -43,6 +43,13 @@ module RuboCop
|
|
|
43
43
|
# end
|
|
44
44
|
# end
|
|
45
45
|
#
|
|
46
|
+
# ## Forms covered
|
|
47
|
+
# The cop catches `default:` set at column-creation time AND `default:`
|
|
48
|
+
# set later via `change_column_default(..., to: <non-nil>)`. Both are
|
|
49
|
+
# the same anti-pattern — a permanent default living in the schema.
|
|
50
|
+
# `change_column_default(..., to: nil)` (removing a default) is not
|
|
51
|
+
# flagged; that is the cleanup form.
|
|
52
|
+
#
|
|
46
53
|
# ## Exception (auto-detected)
|
|
47
54
|
# For performance reasons (large tables with millions of records) or when
|
|
48
55
|
# using `null: false`, you may temporarily set a default and then
|
|
@@ -51,6 +58,10 @@ module RuboCop
|
|
|
51
58
|
# for the same table and column anywhere in the same method body (`def
|
|
52
59
|
# change` / `def up`), including inside `reversible do |dir| dir.up`.
|
|
53
60
|
#
|
|
61
|
+
# The exception applies symmetrically:
|
|
62
|
+
# - `add_column ... default: X` paired with `change_column_default ... to: nil` → no offense
|
|
63
|
+
# - `change_column_default ... to: X` paired with `change_column_default ... to: nil` → no offense
|
|
64
|
+
#
|
|
54
65
|
# ✔ (no offense — two-step pattern auto-detected)
|
|
55
66
|
# add_column :users, :profile_completion_rate, :float, default: 0.0
|
|
56
67
|
# change_column_default :users, :profile_completion_rate, from: 0.0, to: nil
|
|
@@ -62,14 +73,22 @@ module RuboCop
|
|
|
62
73
|
# # bad
|
|
63
74
|
# t.string :status, default: 'active'
|
|
64
75
|
#
|
|
76
|
+
# # bad — permanent default set via change_column_default
|
|
77
|
+
# change_column_default :users, :score, from: nil, to: 0
|
|
78
|
+
#
|
|
65
79
|
# # good
|
|
66
80
|
# add_column :users, :score, :integer
|
|
67
81
|
#
|
|
68
82
|
# # good (temporary default immediately removed — two-step pattern)
|
|
69
83
|
# add_column :users, :score, :integer, default: 0
|
|
70
84
|
# change_column_default :users, :score, from: 0, to: nil
|
|
85
|
+
#
|
|
86
|
+
# # good (removing an existing default)
|
|
87
|
+
# change_column_default :users, :score, from: 0, to: nil
|
|
71
88
|
class AvoidColumnDefault < Base
|
|
72
89
|
MSG = 'Avoid setting `default:` in migrations. Keep business logic defaults in the application layer.'.freeze
|
|
90
|
+
MSG_CHANGE = 'Avoid setting a non-nil default via `change_column_default`. ' \
|
|
91
|
+
'Keep business logic defaults in the application layer.'.freeze
|
|
73
92
|
|
|
74
93
|
COLUMN_METHODS = %i[
|
|
75
94
|
string integer float boolean datetime date text binary decimal
|
|
@@ -84,9 +103,22 @@ module RuboCop
|
|
|
84
103
|
(send _ :change_column_default (sym $_) (sym $_) (hash <(pair (sym :to) (nil)) ...>))
|
|
85
104
|
PATTERN
|
|
86
105
|
|
|
106
|
+
# change_column_default(:table, :col, ..., to: <non-nil>, ...) — captures table + col.
|
|
107
|
+
def_node_matcher :change_column_default_to_non_nil?, <<~PATTERN
|
|
108
|
+
(send _ :change_column_default (sym $_) (sym $_) (hash <(pair (sym :to) !nil) ...>))
|
|
109
|
+
PATTERN
|
|
110
|
+
|
|
87
111
|
def on_send(node)
|
|
88
|
-
|
|
112
|
+
if node.method?(:change_column_default)
|
|
113
|
+
check_change_column_default(node)
|
|
114
|
+
elsif column_method?(node)
|
|
115
|
+
check_column_method(node)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
89
120
|
|
|
121
|
+
def check_column_method(node)
|
|
90
122
|
options = node.arguments.find(&:hash_type?)
|
|
91
123
|
return unless options && has_default_option?(options)
|
|
92
124
|
return if two_step_pattern?(node)
|
|
@@ -94,7 +126,16 @@ module RuboCop
|
|
|
94
126
|
add_offense(find_default_pair(options))
|
|
95
127
|
end
|
|
96
128
|
|
|
97
|
-
|
|
129
|
+
def check_change_column_default(node)
|
|
130
|
+
captures = change_column_default_to_non_nil?(node)
|
|
131
|
+
return unless captures
|
|
132
|
+
|
|
133
|
+
table_name, col_name = captures
|
|
134
|
+
return if cancelled_in_same_method?(node, table_name, col_name)
|
|
135
|
+
|
|
136
|
+
options = node.arguments.find(&:hash_type?)
|
|
137
|
+
add_offense(find_to_pair(options), message: MSG_CHANGE)
|
|
138
|
+
end
|
|
98
139
|
|
|
99
140
|
def column_method?(node)
|
|
100
141
|
node.method?(:add_column) || COLUMN_METHODS.include?(node.method_name)
|
|
@@ -104,10 +145,20 @@ module RuboCop
|
|
|
104
145
|
options.pairs.find { |p| p.key.sym_type? && p.key.value == :default }
|
|
105
146
|
end
|
|
106
147
|
|
|
148
|
+
def find_to_pair(options)
|
|
149
|
+
options.pairs.find { |p| p.key.sym_type? && p.key.value == :to }
|
|
150
|
+
end
|
|
151
|
+
|
|
107
152
|
def two_step_pattern?(node)
|
|
108
153
|
table_name, col_name = extract_table_and_column(node)
|
|
109
154
|
return false unless table_name && col_name
|
|
110
155
|
|
|
156
|
+
cancelled_in_same_method?(node, table_name, col_name)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Is there a `change_column_default(..., to: nil)` for the same
|
|
160
|
+
# (table, col) anywhere in the enclosing method? Used by both forms.
|
|
161
|
+
def cancelled_in_same_method?(node, table_name, col_name)
|
|
111
162
|
enclosing_def = node.each_ancestor(:def).first
|
|
112
163
|
return false unless enclosing_def
|
|
113
164
|
|
|
@@ -2,20 +2,24 @@ module RuboCop
|
|
|
2
2
|
module Cop
|
|
3
3
|
module DevDoc
|
|
4
4
|
module Migration
|
|
5
|
-
# Avoid vague column names like `status` or `
|
|
5
|
+
# Avoid vague column names like `status`, `group`, or `kind`.
|
|
6
6
|
#
|
|
7
7
|
# ## Rationale
|
|
8
8
|
# Use more specific names that describe the domain context. A column
|
|
9
9
|
# named `status` reveals nothing about *what* status it tracks; a column
|
|
10
10
|
# named `processing_status` makes the intent obvious at the call site.
|
|
11
|
+
# `kind` is the same shape — a content-free "what variety" label that
|
|
12
|
+
# reads no better than `type` (which Rails reserves for STI).
|
|
11
13
|
#
|
|
12
14
|
# ❌
|
|
13
15
|
# t.string :status
|
|
14
16
|
# add_column :orders, :group, :integer
|
|
17
|
+
# t.integer :kind
|
|
15
18
|
#
|
|
16
19
|
# ✔️
|
|
17
20
|
# t.string :processing_status
|
|
18
21
|
# add_column :orders, :user_group, :integer
|
|
22
|
+
# t.integer :membership_kind
|
|
19
23
|
#
|
|
20
24
|
# ## Note about `type`
|
|
21
25
|
# `type` is reserved by Rails for Single Table Inheritance (STI). Even
|
|
@@ -29,10 +33,12 @@ module RuboCop
|
|
|
29
33
|
# # bad
|
|
30
34
|
# t.string :status
|
|
31
35
|
# add_column :orders, :group, :integer
|
|
36
|
+
# t.integer :kind
|
|
32
37
|
#
|
|
33
38
|
# # good
|
|
34
39
|
# t.string :processing_status
|
|
35
40
|
# add_column :orders, :user_group, :integer
|
|
41
|
+
# t.integer :membership_kind
|
|
36
42
|
class AvoidVagueColumnNames < Base
|
|
37
43
|
MSG = 'Avoid vague column name `%<name>s`. Use a more specific name that includes context.'.freeze
|
|
38
44
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Avoid ordering by `id` as the primary (sole or leftmost) sort.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# `order(id: ...)` / `order(:id)` sorts by the primary key, which
|
|
9
|
+
# reflects insertion order (and, for UUIDs, random order) rather
|
|
10
|
+
# than a column the reader can reason about. It makes a list read
|
|
11
|
+
# as "most recent" by accident, hides the real ordering intent,
|
|
12
|
+
# and silently breaks the moment rows are imported out of sequence
|
|
13
|
+
# or the primary key type changes.
|
|
14
|
+
#
|
|
15
|
+
# Order by a meaningful business column instead, and reach for
|
|
16
|
+
# `id` only as a **secondary** key to break ties deterministically:
|
|
17
|
+
#
|
|
18
|
+
# ❌ Primary key is not a sort the reader can reason about
|
|
19
|
+
# Snapshot.order(id: :desc)
|
|
20
|
+
#
|
|
21
|
+
# ✔️ Business column carries the intent
|
|
22
|
+
# Snapshot.order(created_at: :desc)
|
|
23
|
+
#
|
|
24
|
+
# ✔️ `id` as a deterministic tiebreaker — never the primary sort
|
|
25
|
+
# Snapshot.order(created_at: :desc, id: :desc)
|
|
26
|
+
#
|
|
27
|
+
# See `best_practices/backend/en/05_controller.md` — always `.order()`
|
|
28
|
+
# when displaying more than one record, and reserve `order(id: ...)`
|
|
29
|
+
# for the rare case where id-ordering is genuinely intended.
|
|
30
|
+
#
|
|
31
|
+
# ## Scope
|
|
32
|
+
# Flags `order` and `reorder` where `:id` is the **sole** ordering
|
|
33
|
+
# column or the **leftmost** (primary) one, in hash form
|
|
34
|
+
# (`order(id: :desc)`), symbol form (`order(:id)`,
|
|
35
|
+
# `order(:id, :name)`), or array form (`order([:id])`).
|
|
36
|
+
#
|
|
37
|
+
# Only the FIRST argument is inspected — it is the primary sort;
|
|
38
|
+
# every later argument is a tiebreaker. Raw-SQL string ordering
|
|
39
|
+
# (`order("id")`, `order("id DESC")`) is the domain of
|
|
40
|
+
# `DevDoc/Rails/AvoidRawSql`, so this cop defers the moment its
|
|
41
|
+
# leading argument is a string, an `Arel.sql(...)`, or anything
|
|
42
|
+
# other than a hash/symbol/array — the primary sort can't be read
|
|
43
|
+
# statically, and flagging would risk a false positive on a
|
|
44
|
+
# tiebreaker.
|
|
45
|
+
#
|
|
46
|
+
# NOTE: Each `order`/`reorder` call is inspected in isolation, so a
|
|
47
|
+
# chained form like `rel.order(:created_at).order(id: :desc)` is
|
|
48
|
+
# flagged even though Rails *appends* chained `order` calls — making
|
|
49
|
+
# `id` a secondary tiebreaker there. The deterministic-tiebreaker
|
|
50
|
+
# idiom this cop endorses is the single-call hash/symbol form
|
|
51
|
+
# (`order(created_at: :desc, id: :desc)`); prefer that, or disable
|
|
52
|
+
# inline with a reason when the chained form is genuinely intended.
|
|
53
|
+
#
|
|
54
|
+
# ## `id` as a secondary key is allowed
|
|
55
|
+
# `order(created_at: :desc, id: :desc)` is the standard pattern for
|
|
56
|
+
# deterministic ordering — `id` only breaks ties after the business
|
|
57
|
+
# column. The cop allows `:id` in any non-primary position:
|
|
58
|
+
#
|
|
59
|
+
# ✔️ `id` breaks ties only — primary sort is `created_at`
|
|
60
|
+
# Snapshot.order(created_at: :desc, id: :desc)
|
|
61
|
+
# Snapshot.order(:created_at, :id)
|
|
62
|
+
#
|
|
63
|
+
# ## Exception
|
|
64
|
+
# When `id` is genuinely the intended primary sort (rare — usually a
|
|
65
|
+
# sign something is special about the code), suppress the offense
|
|
66
|
+
# with an inline disable comment and a brief reason — e.g. an
|
|
67
|
+
# append-only audit log where `id` is creation order, so
|
|
68
|
+
# id-ordering is the real intent rather than an accident.
|
|
69
|
+
#
|
|
70
|
+
# NOTE: A named scope or model method that wraps the offense hides
|
|
71
|
+
# it at the call site — `scope :newest, -> { order(id: :desc) }` is
|
|
72
|
+
# flagged in the model file, but callers of `.newest` are not. The
|
|
73
|
+
# cop flags the definition, which is where the decision belongs.
|
|
74
|
+
#
|
|
75
|
+
# NOTE: Prefer `order(created_at: :desc)` for deterministic
|
|
76
|
+
# results — see `best_practices/backend/en/05_controller.md` #4.
|
|
77
|
+
# The rare legitimate `order(id:)` cases (see Exception above) —
|
|
78
|
+
# e.g. a test grabbing a record a controller just created, where
|
|
79
|
+
# `created_at` ties across one request and `id` is the only
|
|
80
|
+
# reliable insertion-order proxy — take an inline disable with a
|
|
81
|
+
# reason, not a blanket `Exclude` of `spec/**`/`test/**` (that
|
|
82
|
+
# silences accidental id-ordering in tests).
|
|
83
|
+
#
|
|
84
|
+
# @example
|
|
85
|
+
# # bad — `id` is the sole or primary sort
|
|
86
|
+
# Snapshot.order(id: :desc)
|
|
87
|
+
# Snapshot.order(:id)
|
|
88
|
+
# Snapshot.order(id: :desc, created_at: :desc)
|
|
89
|
+
# Snapshot.order(:id, :created_at)
|
|
90
|
+
# submission.snapshots.reorder(id: :asc)
|
|
91
|
+
#
|
|
92
|
+
# # good — business column primary, `id` as a tiebreaker
|
|
93
|
+
# Snapshot.order(created_at: :desc)
|
|
94
|
+
# Snapshot.order(created_at: :desc, id: :desc)
|
|
95
|
+
# Snapshot.order(:created_at, :id)
|
|
96
|
+
class AvoidOrderingById < Base
|
|
97
|
+
MSG = 'Ordering by `id` as the primary sort exposes the primary ' \
|
|
98
|
+
"key's insertion order rather than a meaningful column. " \
|
|
99
|
+
'Order by a business column (e.g. `created_at:`), or move ' \
|
|
100
|
+
'`id` to a secondary position as a deterministic tiebreaker. ' \
|
|
101
|
+
'Disable with a reason when `id` is genuinely the intended ' \
|
|
102
|
+
'primary sort.'.freeze
|
|
103
|
+
|
|
104
|
+
RESTRICT_ON_SEND = %i[order reorder].freeze
|
|
105
|
+
|
|
106
|
+
def on_send(node)
|
|
107
|
+
first = first_ordering_column(node)
|
|
108
|
+
return unless first
|
|
109
|
+
return unless id_column?(first)
|
|
110
|
+
|
|
111
|
+
add_offense(node.loc.selector)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Safe navigation (`rel&.order(id: :desc)`) parses as a `:csend`
|
|
115
|
+
# node, which `on_send` does not visit — alias it so the same
|
|
116
|
+
# check covers both dispatch forms.
|
|
117
|
+
alias on_csend on_send
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# The leftmost ordering column, or nil when the primary sort
|
|
122
|
+
# isn't statically knowable. Only the FIRST argument is inspected
|
|
123
|
+
# — it alone is the primary sort; every later argument is a
|
|
124
|
+
# tiebreaker.
|
|
125
|
+
#
|
|
126
|
+
# order(:created_at, id: :desc) → :created_at (id secondary)
|
|
127
|
+
# order(id: :desc) → :id pair key
|
|
128
|
+
# order([:id]) → :id (array recurses)
|
|
129
|
+
#
|
|
130
|
+
# Returns nil (defers) for a raw-SQL string (`DevDoc/Rails/
|
|
131
|
+
# AvoidRawSql` owns it) AND for any other non-structured leading
|
|
132
|
+
# arg — an `Arel.sql` node, a helper call, a splat, a literal —
|
|
133
|
+
# because the primary sort can't be read statically and flagging
|
|
134
|
+
# would risk a false positive on what may be a tiebreaker.
|
|
135
|
+
def first_ordering_column(node)
|
|
136
|
+
ordering_column_of(node.arguments.first)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Resolve the primary sort from a single argument node, recursing
|
|
140
|
+
# into an array (`order([:id])`) so the array form matches the
|
|
141
|
+
# bare-symbol form.
|
|
142
|
+
def ordering_column_of(arg)
|
|
143
|
+
return nil unless arg
|
|
144
|
+
return nil if arg.str_type? || arg.dstr_type?
|
|
145
|
+
return first_hash_key(arg) if arg.hash_type?
|
|
146
|
+
return ordering_column_of(arg.children.first) if arg.array_type?
|
|
147
|
+
return arg if arg.sym_type?
|
|
148
|
+
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def first_hash_key(hash_node)
|
|
153
|
+
pair = hash_node.pairs.first
|
|
154
|
+
return nil unless pair
|
|
155
|
+
|
|
156
|
+
key = pair.key
|
|
157
|
+
key if key&.sym_type?
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def id_column?(node)
|
|
161
|
+
node.sym_type? && node.value == :id
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -22,12 +22,19 @@ module RuboCop
|
|
|
22
22
|
# class Order < ApplicationRecord
|
|
23
23
|
# def save_with_confirmation_email
|
|
24
24
|
# transaction do
|
|
25
|
-
# save
|
|
26
|
-
#
|
|
25
|
+
# saved = save
|
|
26
|
+
# invoice.save! if saved
|
|
27
27
|
# end
|
|
28
|
+
# OrderMailer.confirmation(self).deliver_later if saved
|
|
28
29
|
# end
|
|
29
30
|
# end
|
|
30
31
|
#
|
|
32
|
+
# The `deliver_later` is *outside* the transaction deliberately —
|
|
33
|
+
# `DevDoc/Rails/NoDeliverLaterInTransaction` flags mailers/jobs
|
|
34
|
+
# queued inside a transaction (the job may run before commit and
|
|
35
|
+
# read stale data). The `saved` local leaks out of the block
|
|
36
|
+
# (Ruby's normal scoping), gating the mailer on actual success.
|
|
37
|
+
#
|
|
31
38
|
# ## When you have multiple call sites needing the same guard
|
|
32
39
|
# The single-method-per-place pattern above works when there is one
|
|
33
40
|
# natural call site. When several controllers/jobs/bulk operations all
|
|
@@ -101,8 +108,11 @@ module RuboCop
|
|
|
101
108
|
#
|
|
102
109
|
# # good
|
|
103
110
|
# def save_with_confirmation
|
|
104
|
-
# transaction
|
|
105
|
-
#
|
|
111
|
+
# transaction do
|
|
112
|
+
# saved = save
|
|
113
|
+
# invoice.save! if saved
|
|
114
|
+
# end
|
|
115
|
+
# send_confirmation if saved
|
|
106
116
|
# end
|
|
107
117
|
class AvoidRailsCallbacks < Base
|
|
108
118
|
CALLBACKS = %i[
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Avoid raw SQL in query methods and `connection.execute`.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# Raw SQL bypasses Rails' adapter abstraction, hides typos in
|
|
9
|
+
# column and table names (a misspelled column silently returns
|
|
10
|
+
# nothing instead of raising `NameError`), is non-reversible in
|
|
11
|
+
# migrations, and ships no audit trail for why raw SQL was chosen
|
|
12
|
+
# over the Rails idiom. Every raw-SQL site should be a deliberate,
|
|
13
|
+
# reviewable choice — not a default.
|
|
14
|
+
#
|
|
15
|
+
# Prefer, in order:
|
|
16
|
+
#
|
|
17
|
+
# - The hash/array form: `where(status: "active")`,
|
|
18
|
+
# `where(status: ["active", "pending"])`.
|
|
19
|
+
# - Parameterized fragments: `where("col = ?", val)`,
|
|
20
|
+
# `where("col = :sym", sym: val)`. The `?` / `:name` placeholder
|
|
21
|
+
# binds the value safely and the column name is the only
|
|
22
|
+
# user-controlled surface.
|
|
23
|
+
# - Arel nodes for database-specific constructs that the hash
|
|
24
|
+
# form can't express.
|
|
25
|
+
#
|
|
26
|
+
# ❌ Raw SQL — typo in `statu` returns nothing, silently
|
|
27
|
+
# User.where("statu = 'active'")
|
|
28
|
+
#
|
|
29
|
+
# ✔️ Hash form — typo raises `ArgumentError` at the query layer
|
|
30
|
+
# User.where(status: "active")
|
|
31
|
+
#
|
|
32
|
+
# ✔️ Parameterized fragment — value bound, column name spelled
|
|
33
|
+
# User.where("status = ?", status)
|
|
34
|
+
#
|
|
35
|
+
# ## Scope
|
|
36
|
+
# The cop covers any string argument to:
|
|
37
|
+
#
|
|
38
|
+
# - **Execution methods** — `execute`, `exec_query`, `exec_update`,
|
|
39
|
+
# `exec_delete`, `exec_insert`, `find_by_sql`, `select_rows`,
|
|
40
|
+
# `select_values`, `select_one`, `select_all`, `update_sql`,
|
|
41
|
+
# `insert_sql`, `delete_sql`, `update_all`, `delete_all`.
|
|
42
|
+
# - **Query fragment methods** — `where`, `having`, `order`,
|
|
43
|
+
# `reorder`, `joins`, `select`, `group`, `from`, `lock`, `pluck`.
|
|
44
|
+
# - **`Arel.sql`** — the explicit opt-in to raw SQL (see below).
|
|
45
|
+
#
|
|
46
|
+
# Applies everywhere — application code AND migrations. A
|
|
47
|
+
# migration's `execute "UPDATE ..."` is the same smell as an
|
|
48
|
+
# app code `where("...")`; both bypass the adapter and offer no
|
|
49
|
+
# audit trail.
|
|
50
|
+
#
|
|
51
|
+
# ## `Arel.sql`
|
|
52
|
+
# Always flagged. `Arel.sql` exists specifically to bypass Rails'
|
|
53
|
+
# raw-SQL protection — reaching for it should require
|
|
54
|
+
# justification, not be a reflex. If a fragment is genuinely
|
|
55
|
+
# needed, disable inline with a reason; otherwise express it as
|
|
56
|
+
# an Arel AST node or a parameterized query.
|
|
57
|
+
#
|
|
58
|
+
# ## Counter updates (`update_all("col = col + 1")`)
|
|
59
|
+
# For **single records**, use `Model.increment_counter(:col, id)`
|
|
60
|
+
# or `Model.update_counters(id, col: 1)` — both generate
|
|
61
|
+
# parameterized SQL and surface typos in the column name. Neither
|
|
62
|
+
# is flagged by `DevDoc/Migration/AvoidBypassingValidation` (they
|
|
63
|
+
# are the Rails-blessed atomic-counter primitives).
|
|
64
|
+
#
|
|
65
|
+
# For **bulk counter updates**, the only clean option is
|
|
66
|
+
# `find_each { |m| Model.increment_counter(:col, m.id) }` — N
|
|
67
|
+
# queries, slow on large tables, but free of the
|
|
68
|
+
# validation-bypass smell. If the N-query cost is genuinely
|
|
69
|
+
# unacceptable, `update_all("col = col + 1")` requires disabling
|
|
70
|
+
# BOTH this cop AND `DevDoc/Migration/AvoidBypassingValidation`
|
|
71
|
+
# with reasons — the friction is the audit trail (locking
|
|
72
|
+
# implications, idempotency on re-runs, and the like are worth
|
|
73
|
+
# a second look).
|
|
74
|
+
#
|
|
75
|
+
# ❌ Single-record counter update via raw SQL
|
|
76
|
+
# User.where(id: id).update_all("login_count = login_count + 1")
|
|
77
|
+
#
|
|
78
|
+
# ✔️ Parameterized — generates safe SQL
|
|
79
|
+
# User.increment_counter(:login_count, id)
|
|
80
|
+
#
|
|
81
|
+
# ## Exception
|
|
82
|
+
# Genuine raw-SQL needs — database-specific DDL
|
|
83
|
+
# (`CREATE INDEX CONCURRENTLY`, `ALTER TYPE ... ADD VALUE`),
|
|
84
|
+
# complex joins that don't fit Arel, analytic queries with CTEs
|
|
85
|
+
# — go through an inline disable comment with a brief reason.
|
|
86
|
+
# The friction is the feature: every raw-SQL site ends up in
|
|
87
|
+
# code review with an articulated justification.
|
|
88
|
+
#
|
|
89
|
+
# ## Migration backfills are NOT an exception
|
|
90
|
+
# A common AI-agent reflex is to justify
|
|
91
|
+
# `execute "UPDATE users SET role = 0 ..."` with "models can
|
|
92
|
+
# drift, so raw SQL is safer." That justification is rejected
|
|
93
|
+
# here. The data-integrity cost of bypassing validations and
|
|
94
|
+
# callbacks outweighs the (rare, usually catchable) risk of a
|
|
95
|
+
# migration failing because a model changed.
|
|
96
|
+
#
|
|
97
|
+
# The correct backfill pattern goes through the model with
|
|
98
|
+
# `save!` — `DevDoc/Migration/AvoidBypassingValidation`
|
|
99
|
+
# enforces this, and `best_practices/backend/en/02_migration.md`
|
|
100
|
+
# shows the shape:
|
|
101
|
+
#
|
|
102
|
+
# ✔️ Model through the migration — validations run, callbacks fire
|
|
103
|
+
# User.where(role: nil).find_each do |user|
|
|
104
|
+
# user.role = 0
|
|
105
|
+
# user.save!
|
|
106
|
+
# end
|
|
107
|
+
#
|
|
108
|
+
# If existing rows would fail validation, fix the data first
|
|
109
|
+
# rather than bypassing validation — that's the signal, not an
|
|
110
|
+
# obstacle.
|
|
111
|
+
#
|
|
112
|
+
# NOTE: Parameterized fragments are detected by the presence of
|
|
113
|
+
# a `?` or `:name` placeholder plus at least one additional
|
|
114
|
+
# argument. A `?` appearing in literal SQL that isn't a
|
|
115
|
+
# placeholder (`where("name LIKE '%?%'")`) is a residual false
|
|
116
|
+
# negative; inline-disable if it surfaces.
|
|
117
|
+
#
|
|
118
|
+
# NOTE: Interpolated strings (`where("col = '#{val}'")`) are
|
|
119
|
+
# ALWAYS flagged — they cannot be parameterized by construction
|
|
120
|
+
# and are a SQL-injection risk. Use the `?` placeholder form.
|
|
121
|
+
#
|
|
122
|
+
# NOTE: Method calls returning strings (`where(some_helper)`)
|
|
123
|
+
# are not flagged — the cop only inspects literal strings.
|
|
124
|
+
# Helper methods that wrap raw SQL should themselves carry the
|
|
125
|
+
# disable.
|
|
126
|
+
#
|
|
127
|
+
# @example
|
|
128
|
+
# # bad — raw SQL
|
|
129
|
+
# execute "UPDATE users SET role = 0 WHERE role IS NULL"
|
|
130
|
+
# User.where("status = 'active'")
|
|
131
|
+
# User.where("status = '#{status}'")
|
|
132
|
+
# User.order("created_at")
|
|
133
|
+
# User.joins("INNER JOIN items ON items.user_id = users.id")
|
|
134
|
+
# User.update_all("login_count = login_count + 1")
|
|
135
|
+
# User.select("DISTINCT status")
|
|
136
|
+
# Arel.sql("NULLS LAST")
|
|
137
|
+
#
|
|
138
|
+
# # good — hash / array / symbol form
|
|
139
|
+
# User.where(status: "active")
|
|
140
|
+
# User.where(status: ["active", "pending"])
|
|
141
|
+
# User.order(:created_at)
|
|
142
|
+
# User.joins(:items)
|
|
143
|
+
# User.update_all(status: "active")
|
|
144
|
+
# User.select(:status)
|
|
145
|
+
#
|
|
146
|
+
# # good — parameterized fragment
|
|
147
|
+
# User.where("status = ?", status)
|
|
148
|
+
# User.where("status = :sym", sym: status)
|
|
149
|
+
# User.find_by_sql("SELECT * FROM users WHERE id = ?", id)
|
|
150
|
+
#
|
|
151
|
+
# # good — counter update on a single record
|
|
152
|
+
# User.increment_counter(:login_count, id)
|
|
153
|
+
class AvoidRawSql < Base
|
|
154
|
+
MSG = 'Avoid raw SQL — prefer the hash/array form, a ' \
|
|
155
|
+
'parameterized fragment, or an Arel node. Disable with ' \
|
|
156
|
+
'a reason when raw SQL is genuinely required.'.freeze
|
|
157
|
+
|
|
158
|
+
EXECUTION_METHODS = %i[
|
|
159
|
+
execute exec_query exec_update exec_delete exec_insert
|
|
160
|
+
find_by_sql select_rows select_values select_one select_all
|
|
161
|
+
update_sql insert_sql delete_sql update_all delete_all
|
|
162
|
+
].freeze
|
|
163
|
+
|
|
164
|
+
FRAGMENT_METHODS = %i[
|
|
165
|
+
where having order reorder joins select group from lock pluck
|
|
166
|
+
].freeze
|
|
167
|
+
|
|
168
|
+
SQL_METHODS = (EXECUTION_METHODS + FRAGMENT_METHODS).freeze
|
|
169
|
+
|
|
170
|
+
RESTRICT_ON_SEND = (SQL_METHODS + [:sql]).freeze
|
|
171
|
+
|
|
172
|
+
def on_send(node)
|
|
173
|
+
if arel_sql?(node)
|
|
174
|
+
check_arel_sql(node)
|
|
175
|
+
elsif SQL_METHODS.include?(node.method_name)
|
|
176
|
+
check_sql_method(node)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
# `Arel.sql("...")` — the explicit opt-in to raw SQL. The
|
|
183
|
+
# receiver must be the `Arel` constant so unrelated
|
|
184
|
+
# `obj.sql(...)` calls don't false-positive.
|
|
185
|
+
def arel_sql?(node)
|
|
186
|
+
node.method_name == :sql &&
|
|
187
|
+
node.receiver&.const_type? &&
|
|
188
|
+
node.receiver.const_name == 'Arel'
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Arel.sql is always flagged (no parameterized exemption) —
|
|
192
|
+
# reaching for it is itself the smell being audited.
|
|
193
|
+
def check_arel_sql(node)
|
|
194
|
+
arg = node.first_argument
|
|
195
|
+
return unless arg && string?(arg)
|
|
196
|
+
|
|
197
|
+
add_offense(arg)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def check_sql_method(node)
|
|
201
|
+
arg = node.first_argument
|
|
202
|
+
return unless arg && string?(arg)
|
|
203
|
+
return if parameterized?(node, arg)
|
|
204
|
+
|
|
205
|
+
add_offense(arg)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def string?(arg)
|
|
209
|
+
arg.str_type? || arg.dstr_type?
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# A literal string (`str`) is parameterized when it contains a
|
|
213
|
+
# `?` or `:name` placeholder AND there's at least one more
|
|
214
|
+
# argument to bind. An interpolated string (`dstr`) is NEVER
|
|
215
|
+
# parameterized — the value is concatenated in, not bound.
|
|
216
|
+
def parameterized?(send_node, string_arg)
|
|
217
|
+
return false if string_arg.dstr_type?
|
|
218
|
+
return false unless send_node.arguments.count > 1
|
|
219
|
+
|
|
220
|
+
value = string_arg.value
|
|
221
|
+
value.include?('?') || value.match?(/:[a-z_][a-z0-9_]*/i)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Prefer `public_send` over `send` when dispatching dynamically.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# `public_send` respects Ruby's method visibility. When the dispatched
|
|
9
|
+
# name should resolve to a public API method (the common case for
|
|
10
|
+
# generic validators, form helpers, attribute readers, etc.), using
|
|
11
|
+
# `public_send`:
|
|
12
|
+
#
|
|
13
|
+
# - **Surfaces typos / misconfig immediately.** A name that drifts to
|
|
14
|
+
# a private internal raises `NoMethodError` instead of silently
|
|
15
|
+
# invoking it.
|
|
16
|
+
# - **Future-proofs the call site.** If the target method later moves
|
|
17
|
+
# to private (refactor, contract narrowing), `public_send` raises
|
|
18
|
+
# on the next run; `send` keeps silently working — potentially
|
|
19
|
+
# masking a real bug.
|
|
20
|
+
# - **Documents intent.** A reader of `public_send` knows the author
|
|
21
|
+
# meant a public-API call. `send` leaves it ambiguous whether
|
|
22
|
+
# private access was wanted.
|
|
23
|
+
#
|
|
24
|
+
# ## Relationship to AvoidSend
|
|
25
|
+
# `DevDoc/Style/AvoidSend` flags *dynamic* dispatch (both `send` and
|
|
26
|
+
# `public_send`) as risky. This cop is orthogonal: it flags `send`
|
|
27
|
+
# specifically, including the cases AvoidSend exempts (literal-symbol
|
|
28
|
+
# args, prefix-string args). The friction asymmetry is intentional —
|
|
29
|
+
# `send` is the deeper exception, so it costs an extra disable:
|
|
30
|
+
#
|
|
31
|
+
# obj.public_send(method_name) # AvoidSend: 1 disable
|
|
32
|
+
# obj.public_send(:foo) # clean
|
|
33
|
+
# obj.send(method_name) # AvoidSend + this cop: 2 disables
|
|
34
|
+
# obj.send(:foo) # this cop only: 1 disable
|
|
35
|
+
#
|
|
36
|
+
# ## When `send` IS the right choice
|
|
37
|
+
# Reach for `send` only when you have an articulable reason to bypass
|
|
38
|
+
# visibility — and leave a comment so the next reader knows it's
|
|
39
|
+
# deliberate:
|
|
40
|
+
#
|
|
41
|
+
# - **Tests** calling a private helper:
|
|
42
|
+
#
|
|
43
|
+
# # rubocop:disable DevDoc/Style/PreferPublicSend -- unit-testing private method
|
|
44
|
+
# assert_equal 5, calculator.send(:internal_carry)
|
|
45
|
+
# # rubocop:enable DevDoc/Style/PreferPublicSend
|
|
46
|
+
#
|
|
47
|
+
# - **Framework / introspection** code that intentionally calls
|
|
48
|
+
# private callbacks (e.g. `record.send(:_run_save_callbacks)`).
|
|
49
|
+
#
|
|
50
|
+
# ## Receiver-less `send` is exempt
|
|
51
|
+
# `send(:foo)` (no explicit receiver) calls a method on `self` and is
|
|
52
|
+
# commonly used inside a class to dispatch to its own private methods.
|
|
53
|
+
# Rewriting to `public_send` would either change semantics (the
|
|
54
|
+
# method must become public) or fail. Same exemption as `AvoidSend`.
|
|
55
|
+
#
|
|
56
|
+
# @example
|
|
57
|
+
# # bad — prefer public_send
|
|
58
|
+
# record.send(attribute)
|
|
59
|
+
# instance.send(:public_method)
|
|
60
|
+
# obj.send("export_#{x}")
|
|
61
|
+
#
|
|
62
|
+
# # good — visibility-respecting dispatch
|
|
63
|
+
# record.public_send(attribute)
|
|
64
|
+
# instance.public_send(:public_method)
|
|
65
|
+
# obj.public_send("export_#{x}")
|
|
66
|
+
#
|
|
67
|
+
# # good — receiver-less send (calling self's own method)
|
|
68
|
+
# send(:internal_helper)
|
|
69
|
+
class PreferPublicSend < Base
|
|
70
|
+
MSG = 'Prefer `public_send` over `send` — it respects method ' \
|
|
71
|
+
'visibility, surfacing typos/misconfig that would otherwise ' \
|
|
72
|
+
'silently invoke a private method. Disable with a reason ' \
|
|
73
|
+
'when bypassing visibility is intentional.'.freeze
|
|
74
|
+
|
|
75
|
+
RESTRICT_ON_SEND = %i[send].freeze
|
|
76
|
+
|
|
77
|
+
def on_send(node)
|
|
78
|
+
return if node.receiver.nil?
|
|
79
|
+
|
|
80
|
+
add_offense(node.loc.selector)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|