rubocop-dev_doc 0.1.0 → 0.3.0
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 +318 -33
- data/lib/dev_doc/test/best_practice_lints.rb +31 -0
- data/lib/dev_doc/test/lints/cron_schedule.rb +345 -0
- data/lib/dev_doc/test/lints/duplicate_snapshot.rb +197 -0
- data/lib/dev_doc/test/lints/no_file_excludes.rb +128 -0
- data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +203 -0
- data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +230 -0
- data/lib/rubocop/cop/dev_doc/migration/amount_column_in_cents.rb +92 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +86 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb +68 -13
- data/lib/rubocop/cop/dev_doc/migration/avoid_conditional_schema_changes.rb +89 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_json_column.rb +18 -3
- data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +121 -0
- data/lib/rubocop/cop/dev_doc/migration/no_create_join_table.rb +53 -0
- data/lib/rubocop/cop/dev_doc/migration/require_primary_key.rb +55 -0
- data/lib/rubocop/cop/dev_doc/migration/require_timestamps.rb +4 -13
- data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +56 -0
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +135 -0
- data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +127 -0
- data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +99 -0
- data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +83 -0
- data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +236 -0
- data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +22 -5
- data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +137 -0
- data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +171 -0
- data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +77 -0
- data/lib/rubocop/cop/dev_doc/route/resources_require_only.rb +29 -15
- data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +56 -22
- data/lib/rubocop/cop/dev_doc/style/avoid_options_hash.rb +102 -0
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +42 -10
- data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +158 -0
- data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +129 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +150 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +118 -0
- data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +91 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +53 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +66 -0
- data/lib/rubocop/cop/dev_doc/test/response_assert_equal.rb +179 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- data/lib/rubocop-dev_doc.rb +1 -0
- metadata +73 -10
- data/lib/rubocop/cop/dev_doc/migration/avoid_update_column.rb +0 -53
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Avoid Rails ActiveRecord callback DSL in model files.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# Rails callbacks (`after_create`, `before_save`, etc.) cause problems
|
|
9
|
+
# with transaction ordering, hidden side effects, and unclear control
|
|
10
|
+
# flow. When a callback fires is not always obvious to the reader, and
|
|
11
|
+
# callbacks can trigger unexpectedly during testing or data migrations.
|
|
12
|
+
#
|
|
13
|
+
# Instead, implement an explicit method that makes the side effect
|
|
14
|
+
# visible at the call site:
|
|
15
|
+
#
|
|
16
|
+
# ❌
|
|
17
|
+
# class Order < ApplicationRecord
|
|
18
|
+
# after_create :send_confirmation_email
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# ✔️
|
|
22
|
+
# class Order < ApplicationRecord
|
|
23
|
+
# def save_with_confirmation_email
|
|
24
|
+
# transaction do
|
|
25
|
+
# save!
|
|
26
|
+
# OrderMailer.confirmation(self).deliver_later
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# ## When you have multiple call sites needing the same guard
|
|
32
|
+
# The single-method-per-place pattern above works when there is one
|
|
33
|
+
# natural call site. When several controllers/jobs/bulk operations all
|
|
34
|
+
# need to enforce the same invariant before destroying or saving, the
|
|
35
|
+
# temptation is to reach for `before_destroy` / `before_save` so
|
|
36
|
+
# nothing slips through. There is a cleaner pattern that gives the
|
|
37
|
+
# same guarantee without a callback — the **fence-and-helper**:
|
|
38
|
+
#
|
|
39
|
+
# 1. Alias the dangerous primitive as private, e.g.
|
|
40
|
+
# alias _unguarded_hard_destroy hard_destroy
|
|
41
|
+
# private :_unguarded_hard_destroy
|
|
42
|
+
# 2. Override the public name to raise with a pointer to the safe
|
|
43
|
+
# method:
|
|
44
|
+
# def hard_destroy
|
|
45
|
+
# raise 'Use SomeModel#safely_hard_destroy — it keeps X in sync'
|
|
46
|
+
# end
|
|
47
|
+
# 3. Expose a `safely_*` helper that does the guard, calls the
|
|
48
|
+
# private alias, and runs any side effects, all wrapped in a
|
|
49
|
+
# transaction so atomicity is a property of the helper rather
|
|
50
|
+
# than of the caller:
|
|
51
|
+
# def safely_hard_destroy
|
|
52
|
+
# if invariant_would_be_violated?
|
|
53
|
+
# errors.add(:base, '...')
|
|
54
|
+
# return false
|
|
55
|
+
# end
|
|
56
|
+
# ApplicationRecord.transaction do
|
|
57
|
+
# _unguarded_hard_destroy
|
|
58
|
+
# cleanup_side_effects!
|
|
59
|
+
# end
|
|
60
|
+
# destroyed?
|
|
61
|
+
# end
|
|
62
|
+
#
|
|
63
|
+
# Direct calls to the dangerous primitive now raise with a helpful
|
|
64
|
+
# message; the only legal path is the safe helper. Every existing
|
|
65
|
+
# caller updates to `safely_*` and inherits the guard automatically.
|
|
66
|
+
#
|
|
67
|
+
# Bonus property: ad-hoc bypass for ops/debugging is clean. Calling
|
|
68
|
+
# `model.send(:_unguarded_hard_destroy)` in the Rails console is
|
|
69
|
+
# scoped to the single call (no global state), self-documenting (you
|
|
70
|
+
# have to type "unguarded"), and greppable. This is intended for
|
|
71
|
+
# console / data-cleanup / debugging use only — if app code reaches
|
|
72
|
+
# for `send(:_unguarded_*)`, that's a design smell and should be
|
|
73
|
+
# solved by extending the helper instead.
|
|
74
|
+
#
|
|
75
|
+
# ## Inline disable
|
|
76
|
+
# We have not yet found a case in our codebases where a callback was
|
|
77
|
+
# the right answer — explicit methods or the fence-and-helper pattern
|
|
78
|
+
# have always covered the legitimate intent. If you think you have a
|
|
79
|
+
# genuine exception, disable this cop inline with a written
|
|
80
|
+
# justification — expect pushback in review:
|
|
81
|
+
#
|
|
82
|
+
# after_create :some_callback # rubocop:disable DevDoc/Rails/AvoidRailsCallbacks
|
|
83
|
+
# # Reason: <explanation>
|
|
84
|
+
#
|
|
85
|
+
# Both the symbol form and the block form are flagged:
|
|
86
|
+
#
|
|
87
|
+
# ❌ Symbol form
|
|
88
|
+
# after_create :send_confirmation_email
|
|
89
|
+
#
|
|
90
|
+
# ❌ Block form
|
|
91
|
+
# after_create { send_confirmation_email }
|
|
92
|
+
#
|
|
93
|
+
# @example
|
|
94
|
+
# # bad
|
|
95
|
+
# after_create :send_confirmation
|
|
96
|
+
# before_save :normalize_name
|
|
97
|
+
# around_update :wrap_in_audit_log
|
|
98
|
+
#
|
|
99
|
+
# # bad (block form)
|
|
100
|
+
# after_create { send_confirmation }
|
|
101
|
+
#
|
|
102
|
+
# # good
|
|
103
|
+
# def save_with_confirmation
|
|
104
|
+
# transaction { save! }
|
|
105
|
+
# send_confirmation
|
|
106
|
+
# end
|
|
107
|
+
class AvoidRailsCallbacks < Base
|
|
108
|
+
CALLBACKS = %i[
|
|
109
|
+
after_create after_create_commit after_save after_update
|
|
110
|
+
after_destroy after_commit after_rollback after_initialize
|
|
111
|
+
after_find after_touch before_create before_save before_update
|
|
112
|
+
before_destroy before_validation after_validation
|
|
113
|
+
around_create around_save around_update around_destroy
|
|
114
|
+
].freeze
|
|
115
|
+
|
|
116
|
+
MSG = 'Avoid `%<method>s` — extract an explicit method (e.g. `save_with_*`) ' \
|
|
117
|
+
'so the side effect is visible at the call site.'.freeze
|
|
118
|
+
RESTRICT_ON_SEND = CALLBACKS
|
|
119
|
+
|
|
120
|
+
def on_send(node)
|
|
121
|
+
add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def on_block(node) # rubocop:disable InternalAffairs/NumblockHandler
|
|
125
|
+
send_node = node.send_node
|
|
126
|
+
return unless CALLBACKS.include?(send_node.method_name)
|
|
127
|
+
|
|
128
|
+
add_offense(send_node.loc.selector, message: format(MSG, method: send_node.method_name))
|
|
129
|
+
end
|
|
130
|
+
alias on_numblock on_block
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Flag bare (return-value-discarded) `save`/`update`/`create` calls inside
|
|
6
|
+
# a `transaction` block.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# Inside a transaction a non-bang `save` / `update` / `create` whose return
|
|
10
|
+
# value is discarded is almost always a bug. The transaction does **not**
|
|
11
|
+
# roll back on a `false` return — execution continues as if the write
|
|
12
|
+
# succeeded, silently producing inconsistent data.
|
|
13
|
+
#
|
|
14
|
+
# Checking the return value (as a condition or assignment) is allowed,
|
|
15
|
+
# as is the bang form. Only the bare-statement form is flagged.
|
|
16
|
+
#
|
|
17
|
+
# ❌ silent failure — transaction does not roll back
|
|
18
|
+
# ApplicationRecord.transaction do
|
|
19
|
+
# @order.save
|
|
20
|
+
# create_child_records(...)
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# ✔️ return value gated — ok
|
|
24
|
+
# ApplicationRecord.transaction do
|
|
25
|
+
# if @order.save
|
|
26
|
+
# create_child_records(...)
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# ✔️ bang method — raises on failure, rolls back
|
|
31
|
+
# ApplicationRecord.transaction do
|
|
32
|
+
# @order.save!
|
|
33
|
+
# create_child_records(...)
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# @example
|
|
37
|
+
# # bad
|
|
38
|
+
# ApplicationRecord.transaction do
|
|
39
|
+
# @order.save
|
|
40
|
+
# create_child_records(params)
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# # good
|
|
44
|
+
# ApplicationRecord.transaction do
|
|
45
|
+
# if @order.save
|
|
46
|
+
# create_child_records(params)
|
|
47
|
+
# end
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# # good
|
|
51
|
+
# ApplicationRecord.transaction do
|
|
52
|
+
# result = @order.save
|
|
53
|
+
# create_child_records(params) if result
|
|
54
|
+
# end
|
|
55
|
+
#
|
|
56
|
+
# # good
|
|
57
|
+
# ApplicationRecord.transaction do
|
|
58
|
+
# @order.save!
|
|
59
|
+
# create_child_records(params)
|
|
60
|
+
# end
|
|
61
|
+
class BangSaveInTransaction < Base
|
|
62
|
+
extend AutoCorrector
|
|
63
|
+
|
|
64
|
+
MSG = 'Use `%<bang>s` inside a `transaction` block, or check its return value. ' \
|
|
65
|
+
'A non-bang call whose return value is discarded does not roll back the transaction on failure.'.freeze
|
|
66
|
+
|
|
67
|
+
FLAGGED_METHODS = %i[save update create].freeze
|
|
68
|
+
|
|
69
|
+
# Node types whose parent always means the return value is consumed.
|
|
70
|
+
CONSUMING_PARENT_TYPES = %i[
|
|
71
|
+
and or return
|
|
72
|
+
send csend
|
|
73
|
+
lvasgn ivasgn cvasgn gvasgn casgn masgn
|
|
74
|
+
array hash pair
|
|
75
|
+
].freeze
|
|
76
|
+
|
|
77
|
+
def on_send(node)
|
|
78
|
+
return unless FLAGGED_METHODS.include?(node.method_name)
|
|
79
|
+
return unless inside_transaction?(node)
|
|
80
|
+
return if return_value_used?(node)
|
|
81
|
+
|
|
82
|
+
bang = :"#{node.method_name}!"
|
|
83
|
+
add_offense(node.loc.selector, message: format(MSG, bang: bang)) do |corrector|
|
|
84
|
+
corrector.replace(node.loc.selector, bang.to_s)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def inside_transaction?(node)
|
|
91
|
+
node.each_ancestor(:block).any? do |ancestor|
|
|
92
|
+
ancestor.method_name == :transaction
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def return_value_used?(node)
|
|
97
|
+
parent = node.parent
|
|
98
|
+
return false if parent.nil?
|
|
99
|
+
|
|
100
|
+
return false if discarding_parent?(parent, node)
|
|
101
|
+
return true if CONSUMING_PARENT_TYPES.include?(parent.type)
|
|
102
|
+
return parent.condition == node if conditional_parent?(parent)
|
|
103
|
+
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def discarding_parent?(parent, node)
|
|
108
|
+
bare_sequence?(parent) || block_body?(parent, node)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def bare_sequence?(parent)
|
|
112
|
+
%i[begin kwbegin].include?(parent.type)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A single-statement block body: the node IS the body of the block.
|
|
116
|
+
def block_body?(parent, node)
|
|
117
|
+
parent.block_type? && parent.body == node
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def conditional_parent?(parent)
|
|
121
|
+
%i[if while until].include?(parent.type)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Enum columns must be backed by a `null: false` database column.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# `null: false` is reserved for cases where NULL has no meaningful
|
|
9
|
+
# interpretation — and an enum is the clearest such case. NULL is
|
|
10
|
+
# outside the enum's domain (a type violation, not one of the defined
|
|
11
|
+
# values), and if "unset" is meaningful it should be modeled as an
|
|
12
|
+
# explicit enum value, never as NULL.
|
|
13
|
+
#
|
|
14
|
+
# The line is drawn here for standardization and non-subjectivity.
|
|
15
|
+
# Whether a *regular* column should be present is a business decision
|
|
16
|
+
# open to debate (could `email` become optional once phone signup
|
|
17
|
+
# exists?), so it is left to model-layer judgment. An enum's
|
|
18
|
+
# non-null-ness is objective — it does not depend on any business
|
|
19
|
+
# decision — so it is enforced mechanically rather than argued
|
|
20
|
+
# column-by-column.
|
|
21
|
+
#
|
|
22
|
+
# This cop is the inverse of `DevDoc/Migration/AvoidNonNull`: that cop
|
|
23
|
+
# runs on the migration and strips `null: false` from regular columns;
|
|
24
|
+
# this cop runs on the model, reads `db/schema.rb`, and requires
|
|
25
|
+
# `null: false` on the column backing each `enum`.
|
|
26
|
+
#
|
|
27
|
+
# ## Interaction with AvoidNonNull
|
|
28
|
+
# An enum is a plain `integer` column, so `AvoidNonNull` cannot tell it
|
|
29
|
+
# apart from a regular integer and WILL flag the `null: false` you add
|
|
30
|
+
# to satisfy this cop. Disable it on that migration with a brief `-- enum`
|
|
31
|
+
# reason, so the migration is self-documenting:
|
|
32
|
+
#
|
|
33
|
+
# # rubocop:disable DevDoc/Migration/AvoidNonNull -- enum
|
|
34
|
+
# add_column :orders, :status, :integer, null: false
|
|
35
|
+
# # rubocop:enable DevDoc/Migration/AvoidNonNull
|
|
36
|
+
#
|
|
37
|
+
# NOTE: This cop reads `db/schema.rb` and does nothing if it is absent
|
|
38
|
+
# (e.g. projects using `structure.sql`). It also relies on the schema
|
|
39
|
+
# being current, resolves the table name by Rails convention (STI,
|
|
40
|
+
# `self.table_name` overrides, or namespaced models may not resolve),
|
|
41
|
+
# recognizes only the positional `enum :name, …` form, and skips
|
|
42
|
+
# silently if the backing column cannot be found in the schema.
|
|
43
|
+
#
|
|
44
|
+
# @example
|
|
45
|
+
# # bad - the `status` column is nullable in db/schema.rb
|
|
46
|
+
# class Order < ApplicationRecord
|
|
47
|
+
# enum :status, { active: 0, archived: 1 }
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# # good - the `status` column is `null: false` in db/schema.rb
|
|
51
|
+
# class Order < ApplicationRecord
|
|
52
|
+
# enum :status, { active: 0, archived: 1 }
|
|
53
|
+
# end
|
|
54
|
+
class EnumColumnNotNull < Base
|
|
55
|
+
include ActiveRecordHelper
|
|
56
|
+
|
|
57
|
+
MSG = 'Enum column `%<name>s` should be `null: false` — NULL is outside an enum\'s domain. ' \
|
|
58
|
+
'Model "unset" as an explicit enum value.'.freeze
|
|
59
|
+
|
|
60
|
+
RESTRICT_ON_SEND = %i[enum].freeze
|
|
61
|
+
|
|
62
|
+
def_node_matcher :enum_call, <<~PATTERN
|
|
63
|
+
(send nil? :enum (sym $_) ...)
|
|
64
|
+
PATTERN
|
|
65
|
+
|
|
66
|
+
def on_send(node)
|
|
67
|
+
name = nullable_enum_name(node)
|
|
68
|
+
return unless name
|
|
69
|
+
|
|
70
|
+
add_offense(node, message: format(MSG, name: name))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# The enum's attribute name if it is backed by a nullable column, else nil.
|
|
76
|
+
def nullable_enum_name(node)
|
|
77
|
+
return unless schema
|
|
78
|
+
|
|
79
|
+
name = enum_call(node)
|
|
80
|
+
klass = name && class_node(node)
|
|
81
|
+
return unless klass
|
|
82
|
+
|
|
83
|
+
column = enum_column(klass, name)
|
|
84
|
+
name if column && !column.not_null
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def enum_column(klass, name)
|
|
88
|
+
table = schema.table_by(name: table_name(klass))
|
|
89
|
+
table&.columns&.find { |c| c.name == name.to_s }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def class_node(node)
|
|
93
|
+
node.each_ancestor.find(&:class_type?)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Every Rails `enum` declaration must be paired with `enum_symbolize`
|
|
6
|
+
# so the reader returns a symbol instead of Rails' default string form.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# Strings and symbols are not equal in Ruby (`:foo == 'foo'` is `false`).
|
|
10
|
+
# Rails' `enum` macro defines a reader that returns the string form, so
|
|
11
|
+
# downstream comparisons like `record.status == :active` silently fail.
|
|
12
|
+
# Pairing `enum :status, …` with `enum_symbolize :status` overrides the
|
|
13
|
+
# reader to hand back a symbol, eliminating the footgun.
|
|
14
|
+
#
|
|
15
|
+
# See `best_practices/backend/en/01a_defensive_programming.md` item 7.
|
|
16
|
+
#
|
|
17
|
+
# ❌
|
|
18
|
+
# class Order < ApplicationRecord
|
|
19
|
+
# enum :payment_status, { draft: 0, pending: 1, finalized: 2 }
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# ✔
|
|
23
|
+
# class Order < ApplicationRecord
|
|
24
|
+
# enum :payment_status, { draft: 0, pending: 1, finalized: 2 }
|
|
25
|
+
#
|
|
26
|
+
# enum_symbolize :payment_status
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# `enum_symbolize` accepts multiple names so several enums can be
|
|
30
|
+
# paired in one call:
|
|
31
|
+
#
|
|
32
|
+
# ✔
|
|
33
|
+
# enum_symbolize :payment_status, :finalize_intent
|
|
34
|
+
#
|
|
35
|
+
# @example
|
|
36
|
+
# # bad
|
|
37
|
+
# enum :status, { active: 0, archived: 1 }
|
|
38
|
+
#
|
|
39
|
+
# # good
|
|
40
|
+
# enum :status, { active: 0, archived: 1 }
|
|
41
|
+
# enum_symbolize :status
|
|
42
|
+
class EnumMustBeSymbolized < Base
|
|
43
|
+
MSG = 'Pair `enum :%<name>s` with `enum_symbolize :%<name>s` so the reader returns a symbol ' \
|
|
44
|
+
'(see backend/01a_defensive_programming.md item 7).'.freeze
|
|
45
|
+
|
|
46
|
+
def_node_matcher :enum_call, <<~PATTERN
|
|
47
|
+
(send nil? :enum (sym $_) ...)
|
|
48
|
+
PATTERN
|
|
49
|
+
|
|
50
|
+
def_node_matcher :enum_symbolize_call, <<~PATTERN
|
|
51
|
+
(send nil? :enum_symbolize $...)
|
|
52
|
+
PATTERN
|
|
53
|
+
|
|
54
|
+
def on_class(node)
|
|
55
|
+
body = node.body
|
|
56
|
+
return unless body
|
|
57
|
+
|
|
58
|
+
statements = body.begin_type? ? body.children : [body]
|
|
59
|
+
|
|
60
|
+
enum_decls = []
|
|
61
|
+
symbolized = []
|
|
62
|
+
|
|
63
|
+
statements.each do |stmt|
|
|
64
|
+
next unless stmt.respond_to?(:send_type?) && stmt.send_type?
|
|
65
|
+
|
|
66
|
+
if (name = enum_call(stmt))
|
|
67
|
+
enum_decls << [name, stmt]
|
|
68
|
+
elsif (args = enum_symbolize_call(stmt))
|
|
69
|
+
symbolized.concat(args.select(&:sym_type?).map(&:value))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
enum_decls.each do |name, decl|
|
|
74
|
+
next if symbolized.include?(name)
|
|
75
|
+
|
|
76
|
+
add_offense(decl, message: format(MSG, name: name))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Avoid block-form predicates on ActiveRecord relations.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# `.count { }`, `.reject { }`, `.select { }`, `.any? { }`, and
|
|
9
|
+
# `.find { }` accept a block, which silently converts the relation to
|
|
10
|
+
# an Array — every row is loaded into Ruby memory and filtered there,
|
|
11
|
+
# defeating database indexes and pagination.
|
|
12
|
+
#
|
|
13
|
+
# Push the predicate into SQL with `.where(...)` or a model scope so
|
|
14
|
+
# the database does the filtering.
|
|
15
|
+
#
|
|
16
|
+
# ❌ Loads every pending record into memory before counting
|
|
17
|
+
# pending_subscriptions.count { |s| !s.expired_for_context? }
|
|
18
|
+
#
|
|
19
|
+
# ✔️ Becomes a SQL COUNT via a scope
|
|
20
|
+
# class Subscription < ApplicationRecord
|
|
21
|
+
# scope :not_expired_for_context, ->(context) { ... }
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# pending_subscriptions.not_expired_for_context(context).count
|
|
25
|
+
#
|
|
26
|
+
# ## Exception
|
|
27
|
+
# Some predicates genuinely can't be expressed in SQL (decrypted
|
|
28
|
+
# attributes, non-trivial Ruby logic). For those, add a
|
|
29
|
+
# `# rubocop:disable` with a brief reason.
|
|
30
|
+
#
|
|
31
|
+
# ## Excluded receivers
|
|
32
|
+
# To keep false positives low, the cop skips receivers that clearly
|
|
33
|
+
# aren't AR relations: array literals (`[...]`), hash literals
|
|
34
|
+
# (`{...}`), screaming-case constants (e.g. `PRICING_PLANS`), and
|
|
35
|
+
# send-chains ending in a method known to return a non-Relation:
|
|
36
|
+
# * Array-returning — `pluck`, `to_a`, `map`, `flatten`, `compact`,
|
|
37
|
+
# `uniq`, `sort`, `sort_by`, `reduce`, `inject`, `each_with_object`,
|
|
38
|
+
# `zip`, `take`, `drop`, `group_by`, `partition`, `tally`,
|
|
39
|
+
# `chunk_while`, `slice_when`, etc.
|
|
40
|
+
# * Hash-returning — `slice`, `except`, `merge`, `transform_values`,
|
|
41
|
+
# `transform_keys`, `to_h`, `compact_blank`,
|
|
42
|
+
# `with_indifferent_access`.
|
|
43
|
+
# * Enumerator-returning — `each_with_index`, `each_slice`,
|
|
44
|
+
# `each_cons`, `lazy`, `with_index`, `with_object`. Calling these
|
|
45
|
+
# on a Relation forces eager loading; the next `.select`/etc. then
|
|
46
|
+
# operates on an in-memory Enumerator, so pushing into SQL is no
|
|
47
|
+
# longer possible.
|
|
48
|
+
#
|
|
49
|
+
# Parenthesised receivers (`(expr).select { ... }`) are looked through
|
|
50
|
+
# to `expr` so the rules above still apply.
|
|
51
|
+
#
|
|
52
|
+
# ## Excluded block shapes
|
|
53
|
+
# Even when the receiver is opaque (a local/instance variable, method
|
|
54
|
+
# parameter, etc.), the block itself sometimes proves the element
|
|
55
|
+
# can't be an AR record:
|
|
56
|
+
# * **2+ block arguments** — `|k, v|`, `|item, _index|`, etc. AR
|
|
57
|
+
# relations only yield single records; multi-arg destructuring
|
|
58
|
+
# means the iterator is Hash#each, zip, or similar.
|
|
59
|
+
# * **Symbol-key indexing on the block arg** — `arg[:foo]` proves
|
|
60
|
+
# the element is a Hash (AR's `[]` is rarely called with literal
|
|
61
|
+
# symbol keys).
|
|
62
|
+
# * **Single-char string indexing on the block arg** — `c[0] == '+'`
|
|
63
|
+
# proves the element is a String.
|
|
64
|
+
#
|
|
65
|
+
# ## Configuration
|
|
66
|
+
# `AdditionalNonRelationMethods` (default `[]`): per-project list of
|
|
67
|
+
# method names that return non-Relation collections. Useful when a
|
|
68
|
+
# codebase has its own helper methods returning plain Arrays / Hashes
|
|
69
|
+
# (e.g. a presenter factory) — adding them here lets the cop skip
|
|
70
|
+
# send-chains ending in those methods. Example:
|
|
71
|
+
#
|
|
72
|
+
# DevDoc/Rails/NoBlockPredicateOnRelation:
|
|
73
|
+
# AdditionalNonRelationMethods:
|
|
74
|
+
# - all_items
|
|
75
|
+
# - for_account
|
|
76
|
+
# - gst_registration_ranges
|
|
77
|
+
#
|
|
78
|
+
# NOTE: The cop cannot determine whether a local variable, instance
|
|
79
|
+
# variable, or method parameter is an AR relation or a plain
|
|
80
|
+
# collection. When the receiver is in fact a plain Array/Hash, add
|
|
81
|
+
# `# rubocop:disable DevDoc/Rails/NoBlockPredicateOnRelation` with a
|
|
82
|
+
# brief reason — the friction is intentional and ensures the choice
|
|
83
|
+
# is reviewed.
|
|
84
|
+
#
|
|
85
|
+
# @example
|
|
86
|
+
# # bad
|
|
87
|
+
# pending_memberships.count { |m| !m.expired? }
|
|
88
|
+
#
|
|
89
|
+
# # bad
|
|
90
|
+
# user.posts.reject { |post| post.archived? }
|
|
91
|
+
#
|
|
92
|
+
# # bad
|
|
93
|
+
# Model.where(active: true).any? { |r| r.flagged? }
|
|
94
|
+
#
|
|
95
|
+
# # good
|
|
96
|
+
# pending_memberships.not_expired.count
|
|
97
|
+
#
|
|
98
|
+
# # good (receiver returns Array — excluded)
|
|
99
|
+
# user.posts.pluck(:title).reject(&:blank?)
|
|
100
|
+
#
|
|
101
|
+
# # good (Hash#values — excluded)
|
|
102
|
+
# PRICING_PLANS.reject { |_k, v| v.archived? }
|
|
103
|
+
class NoBlockPredicateOnRelation < Base
|
|
104
|
+
MSG = '`%<method>s` with a block loads every row into Ruby. ' \
|
|
105
|
+
'Push the predicate into SQL with `.where(...)` or a model scope.'.freeze
|
|
106
|
+
|
|
107
|
+
RESTRICT_ON_SEND = %i[count reject select find any?].freeze
|
|
108
|
+
|
|
109
|
+
# Methods whose return value is known to be a non-Relation collection
|
|
110
|
+
# (Array, Hash, or Enumerator). When a `.select`/`.reject`/etc. with a
|
|
111
|
+
# block is chained onto a call to one of these, the block runs over
|
|
112
|
+
# the materialised collection — pushing into SQL isn't possible.
|
|
113
|
+
NON_RELATION_RETURNING_METHODS = %i[
|
|
114
|
+
pluck pluck_to_hash
|
|
115
|
+
to_a to_ary
|
|
116
|
+
values keys
|
|
117
|
+
map flat_map collect collect_concat filter_map
|
|
118
|
+
flatten compact uniq sort sort_by reverse
|
|
119
|
+
reduce inject each_with_object
|
|
120
|
+
split lines chars bytes
|
|
121
|
+
zip take drop drop_while take_while
|
|
122
|
+
group_by partition tally tally_by chunk_while slice_when
|
|
123
|
+
|
|
124
|
+
slice except merge transform_values transform_keys to_h
|
|
125
|
+
compact_blank with_indifferent_access index_by index_with
|
|
126
|
+
|
|
127
|
+
each_with_index each_slice each_cons each_entry each_key each_value each_pair
|
|
128
|
+
chunk slice_before slice_after lazy with_index with_object
|
|
129
|
+
].freeze
|
|
130
|
+
|
|
131
|
+
def on_send(node)
|
|
132
|
+
return unless node.block_literal?
|
|
133
|
+
return if node.receiver.nil?
|
|
134
|
+
return if excluded_receiver?(node.receiver)
|
|
135
|
+
return if excluded_block?(node)
|
|
136
|
+
|
|
137
|
+
add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def excluded_receiver?(receiver)
|
|
143
|
+
return true if receiver.array_type? || receiver.hash_type?
|
|
144
|
+
return true if receiver.const_type? && screaming_case_const?(receiver)
|
|
145
|
+
return excluded_receiver?(receiver.children.first) if receiver.begin_type?
|
|
146
|
+
return true if receiver.send_type? && non_relation_method?(receiver.method_name)
|
|
147
|
+
|
|
148
|
+
false
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Merge built-in known-safe methods with project-specific ones from
|
|
152
|
+
# `AdditionalNonRelationMethods` in .rubocop.yml. The config knob
|
|
153
|
+
# lets each project opt-in their own domain methods (e.g. a
|
|
154
|
+
# `CashBookEntry.for_account` that returns an Array of plain Ruby
|
|
155
|
+
# presenters) without modifying this cop.
|
|
156
|
+
def non_relation_method?(method_name)
|
|
157
|
+
return true if NON_RELATION_RETURNING_METHODS.include?(method_name)
|
|
158
|
+
|
|
159
|
+
additional = cop_config['AdditionalNonRelationMethods'] || []
|
|
160
|
+
additional.map(&:to_sym).include?(method_name)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def screaming_case_const?(const_node)
|
|
164
|
+
const_node.short_name.to_s.match?(/\A[A-Z][A-Z0-9_]*\z/)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Look at the block parameters and body for evidence that the
|
|
168
|
+
# iterated element can't be an ActiveRecord record.
|
|
169
|
+
def excluded_block?(send_node)
|
|
170
|
+
block_node = send_node.block_node
|
|
171
|
+
return false unless block_node
|
|
172
|
+
|
|
173
|
+
args = block_node.arguments
|
|
174
|
+
return false unless args
|
|
175
|
+
|
|
176
|
+
# 2+ arg destructuring means the iterator yields a pair/tuple
|
|
177
|
+
# (Hash#each, zip, etc.). AR relations only yield single records.
|
|
178
|
+
return true if args.children.length >= 2
|
|
179
|
+
|
|
180
|
+
# Single-arg block: inspect how the arg is used inside the body.
|
|
181
|
+
return false unless args.children.length == 1
|
|
182
|
+
|
|
183
|
+
arg_node = args.children.first
|
|
184
|
+
arg_name = block_arg_name(arg_node)
|
|
185
|
+
return false unless arg_name
|
|
186
|
+
|
|
187
|
+
body = block_node.body
|
|
188
|
+
return false unless body
|
|
189
|
+
|
|
190
|
+
block_arg_indicates_non_record?(body, arg_name)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Extract the simple name of a block argument, regardless of whether
|
|
194
|
+
# it's a regular arg, optional arg, or splat. Skips destructured
|
|
195
|
+
# `(a, b)` (handled by the 2+ arg check at a structural level).
|
|
196
|
+
def block_arg_name(arg_node)
|
|
197
|
+
return nil unless arg_node.respond_to?(:children)
|
|
198
|
+
|
|
199
|
+
arg_node.children.first if %i[arg optarg restarg].include?(arg_node.type)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Return true if the block body uses `arg` in a way that's only
|
|
203
|
+
# legal for non-AR elements:
|
|
204
|
+
# * Symbol-key indexing — `arg[:foo]` — implies Hash
|
|
205
|
+
# * Single-character string-literal indexing — `arg[0] == '+'` —
|
|
206
|
+
# implies String
|
|
207
|
+
def block_arg_indicates_non_record?(body, arg_name)
|
|
208
|
+
body.each_descendant(:send) do |send|
|
|
209
|
+
next unless send.method_name == :[]
|
|
210
|
+
next unless send.receiver&.lvar_type?
|
|
211
|
+
next unless send.receiver.children.first == arg_name
|
|
212
|
+
next unless send.arguments.length == 1
|
|
213
|
+
|
|
214
|
+
key = send.first_argument
|
|
215
|
+
return true if key.sym_type?
|
|
216
|
+
return true if key.int_type? && compared_to_single_char_string?(send)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
false
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# `arg[0] == '+'` — the [] send is one side of a `==` or `!=`
|
|
223
|
+
# whose other side is a single-char string literal.
|
|
224
|
+
def compared_to_single_char_string?(index_send)
|
|
225
|
+
parent = index_send.parent
|
|
226
|
+
return false unless parent&.send_type?
|
|
227
|
+
return false unless %i[== !=].include?(parent.method_name)
|
|
228
|
+
|
|
229
|
+
other = parent.receiver.equal?(index_send) ? parent.first_argument : parent.receiver
|
|
230
|
+
other&.str_type? && other.value.length == 1
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|