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
|
@@ -43,18 +43,18 @@ module RuboCop
|
|
|
43
43
|
# end
|
|
44
44
|
# end
|
|
45
45
|
#
|
|
46
|
-
# ## Exception
|
|
46
|
+
# ## Exception (auto-detected)
|
|
47
47
|
# For performance reasons (large tables with millions of records) or when
|
|
48
48
|
# using `null: false`, you may temporarily set a default and then
|
|
49
|
-
# immediately remove it in the same migration
|
|
49
|
+
# immediately remove it in the same migration. The cop suppresses the
|
|
50
|
+
# offense when it detects a matching `change_column_default ..., to: nil`
|
|
51
|
+
# for the same table and column anywhere in the same method body (`def
|
|
52
|
+
# change` / `def up`), including inside `reversible do |dir| dir.up`.
|
|
50
53
|
#
|
|
51
|
-
# ✔
|
|
54
|
+
# ✔ (no offense — two-step pattern auto-detected)
|
|
52
55
|
# add_column :users, :profile_completion_rate, :float, default: 0.0
|
|
53
56
|
# change_column_default :users, :profile_completion_rate, from: 0.0, to: nil
|
|
54
57
|
#
|
|
55
|
-
# NOTE: This cop currently flags the first line of the exception pattern.
|
|
56
|
-
# The follow-up `change_column_default ..., to: nil` is not auto-detected.
|
|
57
|
-
#
|
|
58
58
|
# @example
|
|
59
59
|
# # bad
|
|
60
60
|
# add_column :users, :score, :integer, default: 0
|
|
@@ -65,7 +65,7 @@ module RuboCop
|
|
|
65
65
|
# # good
|
|
66
66
|
# add_column :users, :score, :integer
|
|
67
67
|
#
|
|
68
|
-
# # good (temporary default immediately removed)
|
|
68
|
+
# # good (temporary default immediately removed — two-step pattern)
|
|
69
69
|
# add_column :users, :score, :integer, default: 0
|
|
70
70
|
# change_column_default :users, :score, from: 0, to: nil
|
|
71
71
|
class AvoidColumnDefault < Base
|
|
@@ -80,19 +80,74 @@ module RuboCop
|
|
|
80
80
|
(hash <(pair (sym :default) _) ...>)
|
|
81
81
|
PATTERN
|
|
82
82
|
|
|
83
|
+
def_node_matcher :change_column_default_to_nil?, <<~PATTERN
|
|
84
|
+
(send _ :change_column_default (sym $_) (sym $_) (hash <(pair (sym :to) (nil)) ...>))
|
|
85
|
+
PATTERN
|
|
86
|
+
|
|
83
87
|
def on_send(node)
|
|
84
|
-
return unless
|
|
88
|
+
return unless column_method?(node)
|
|
89
|
+
|
|
90
|
+
options = node.arguments.find(&:hash_type?)
|
|
91
|
+
return unless options && has_default_option?(options)
|
|
92
|
+
return if two_step_pattern?(node)
|
|
85
93
|
|
|
86
|
-
|
|
94
|
+
add_offense(find_default_pair(options))
|
|
87
95
|
end
|
|
88
96
|
|
|
89
97
|
private
|
|
90
98
|
|
|
91
|
-
def
|
|
92
|
-
|
|
99
|
+
def column_method?(node)
|
|
100
|
+
node.method?(:add_column) || COLUMN_METHODS.include?(node.method_name)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def find_default_pair(options)
|
|
104
|
+
options.pairs.find { |p| p.key.sym_type? && p.key.value == :default }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def two_step_pattern?(node)
|
|
108
|
+
table_name, col_name = extract_table_and_column(node)
|
|
109
|
+
return false unless table_name && col_name
|
|
110
|
+
|
|
111
|
+
enclosing_def = node.each_ancestor(:def).first
|
|
112
|
+
return false unless enclosing_def
|
|
113
|
+
|
|
114
|
+
enclosing_def.each_descendant(:send).any? do |sibling|
|
|
115
|
+
cancels_default?(sibling, table_name, col_name)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def extract_table_and_column(node)
|
|
120
|
+
node.method?(:add_column) ? add_column_pair(node) : column_def_pair(node)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def add_column_pair(node)
|
|
124
|
+
t = node.arguments[0]
|
|
125
|
+
c = node.arguments[1]
|
|
126
|
+
[t.value, c.value] if t&.sym_type? && c&.sym_type?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def column_def_pair(node)
|
|
130
|
+
c = node.arguments[0]
|
|
131
|
+
return unless c&.sym_type?
|
|
132
|
+
|
|
133
|
+
t = enclosing_table_sym(node)
|
|
134
|
+
[t.value, c.value] if t
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def enclosing_table_sym(node)
|
|
138
|
+
table_block = node.each_ancestor(:block).find do |b|
|
|
139
|
+
%i[create_table change_table].include?(b.method_name)
|
|
140
|
+
end
|
|
141
|
+
return unless table_block
|
|
142
|
+
|
|
143
|
+
arg = table_block.send_node.arguments[0]
|
|
144
|
+
arg if arg&.sym_type?
|
|
145
|
+
end
|
|
93
146
|
|
|
94
|
-
|
|
95
|
-
|
|
147
|
+
def cancels_default?(node, table_name, col_name)
|
|
148
|
+
change_column_default_to_nil?(node) do |t, c|
|
|
149
|
+
t == table_name && c == col_name
|
|
150
|
+
end
|
|
96
151
|
end
|
|
97
152
|
end
|
|
98
153
|
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Migration
|
|
5
|
+
# Flag conditional schema-change helpers (`add_column_if_not_exists`,
|
|
6
|
+
# `column_exists?`, etc.) inside migration files.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# Migrations are deterministic state transitions: "DB was at state N;
|
|
10
|
+
# after this migration it is at state N+1." Conditional schema helpers
|
|
11
|
+
# imply "I don't know what state the DB is in," which contradicts the
|
|
12
|
+
# migration model and hides schema drift.
|
|
13
|
+
#
|
|
14
|
+
# If a column "might already exist," that is a symptom — investigate
|
|
15
|
+
# why before papering over it with a defensive guard.
|
|
16
|
+
#
|
|
17
|
+
# The escape hatch is a per-line `rubocop:disable` comment with a
|
|
18
|
+
# rationale explaining the known-drift repair.
|
|
19
|
+
#
|
|
20
|
+
# ❌ hides drift; state is unknown
|
|
21
|
+
# add_column_if_not_exists :users, :something, :string
|
|
22
|
+
# add_column :users, :bar, :string unless column_exists?(:users, :bar)
|
|
23
|
+
#
|
|
24
|
+
# ✔️ declarative state transition
|
|
25
|
+
# add_column :users, :something, :string
|
|
26
|
+
#
|
|
27
|
+
# ✔️ documented one-shot drift repair (escape hatch)
|
|
28
|
+
# # rubocop:disable DevDoc/Migration/AvoidConditionalSchemaChanges
|
|
29
|
+
# add_column_if_not_exists :users, :something, :string
|
|
30
|
+
# # rubocop:enable DevDoc/Migration/AvoidConditionalSchemaChanges
|
|
31
|
+
#
|
|
32
|
+
# @example
|
|
33
|
+
# # bad
|
|
34
|
+
# add_column_if_not_exists :users, :something, :string
|
|
35
|
+
#
|
|
36
|
+
# # bad
|
|
37
|
+
# add_index_if_not_exists :users, :email
|
|
38
|
+
#
|
|
39
|
+
# # bad
|
|
40
|
+
# remove_column_if_exists :users, :legacy
|
|
41
|
+
#
|
|
42
|
+
# # bad (predicate guard shape)
|
|
43
|
+
# add_column :users, :foo, :string unless column_exists?(:users, :foo)
|
|
44
|
+
#
|
|
45
|
+
# # good
|
|
46
|
+
# add_column :users, :something, :string
|
|
47
|
+
class AvoidConditionalSchemaChanges < Base
|
|
48
|
+
IF_NOT_EXISTS_METHODS = %i[
|
|
49
|
+
add_column_if_not_exists
|
|
50
|
+
add_index_if_not_exists
|
|
51
|
+
add_foreign_key_if_not_exists
|
|
52
|
+
add_reference_if_not_exists
|
|
53
|
+
remove_column_if_exists
|
|
54
|
+
remove_index_if_exists
|
|
55
|
+
remove_foreign_key_if_exists
|
|
56
|
+
remove_reference_if_exists
|
|
57
|
+
].freeze
|
|
58
|
+
|
|
59
|
+
EXISTENCE_PREDICATES = %i[
|
|
60
|
+
column_exists?
|
|
61
|
+
table_exists?
|
|
62
|
+
index_exists?
|
|
63
|
+
foreign_key_exists?
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
66
|
+
MSG_IF_NOT_EXISTS =
|
|
67
|
+
'`%<method>s` hides schema drift. Use the non-conditional form and investigate ' \
|
|
68
|
+
'why states diverge. Suppress with a `rubocop:disable` comment only for documented ' \
|
|
69
|
+
'one-shot drift repairs.'.freeze
|
|
70
|
+
|
|
71
|
+
MSG_PREDICATE =
|
|
72
|
+
'`%<method>s` guard hides schema drift. Use unconditional schema operations and ' \
|
|
73
|
+
'investigate why states diverge. Suppress with a `rubocop:disable` comment only for ' \
|
|
74
|
+
'documented one-shot drift repairs.'.freeze
|
|
75
|
+
|
|
76
|
+
def on_send(node)
|
|
77
|
+
if IF_NOT_EXISTS_METHODS.include?(node.method_name)
|
|
78
|
+
add_offense(node.loc.selector,
|
|
79
|
+
message: format(MSG_IF_NOT_EXISTS, method: node.method_name))
|
|
80
|
+
elsif EXISTENCE_PREDICATES.include?(node.method_name)
|
|
81
|
+
add_offense(node.loc.selector,
|
|
82
|
+
message: format(MSG_PREDICATE, method: node.method_name))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -12,27 +12,42 @@ module RuboCop
|
|
|
12
12
|
#
|
|
13
13
|
# ❌
|
|
14
14
|
# t.json :metadata
|
|
15
|
+
# add_column :users, :metadata, :json
|
|
15
16
|
#
|
|
16
17
|
# ✔️
|
|
17
18
|
# t.jsonb :metadata
|
|
19
|
+
# add_column :users, :metadata, :jsonb
|
|
18
20
|
#
|
|
19
21
|
# @example
|
|
20
22
|
# # bad
|
|
21
23
|
# t.json :metadata
|
|
24
|
+
# add_column :users, :metadata, :json
|
|
22
25
|
#
|
|
23
26
|
# # good
|
|
24
27
|
# t.jsonb :metadata
|
|
28
|
+
# add_column :users, :metadata, :jsonb
|
|
25
29
|
class AvoidJsonColumn < Base
|
|
26
30
|
extend AutoCorrector
|
|
27
31
|
|
|
28
32
|
MSG = 'Use `jsonb` instead of `json`. `jsonb` supports indexing and is faster to read.'.freeze
|
|
29
|
-
RESTRICT_ON_SEND = %i[json].freeze
|
|
33
|
+
RESTRICT_ON_SEND = %i[json add_column].freeze
|
|
30
34
|
|
|
31
35
|
def on_send(node)
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
if node.method_name == :add_column
|
|
37
|
+
check_add_column(node)
|
|
38
|
+
else
|
|
39
|
+
add_offense(node.loc.selector) { |c| c.replace(node.loc.selector, 'jsonb') }
|
|
34
40
|
end
|
|
35
41
|
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def check_add_column(node)
|
|
46
|
+
type_arg = node.arguments[2]
|
|
47
|
+
return unless type_arg&.sym_type? && type_arg.value == :json
|
|
48
|
+
|
|
49
|
+
add_offense(type_arg) { |c| c.replace(type_arg, ':jsonb') }
|
|
50
|
+
end
|
|
36
51
|
end
|
|
37
52
|
end
|
|
38
53
|
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Migration
|
|
5
|
+
# Avoid `null: false` on regular columns.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# `null: false` on a regular column bakes a business rule (presence) into
|
|
9
|
+
# the schema. Presence belongs in the application layer (model
|
|
10
|
+
# validations), where it is easy to change.
|
|
11
|
+
#
|
|
12
|
+
# The test for whether `null: false` is justified is "what would NULL
|
|
13
|
+
# mean for this column?":
|
|
14
|
+
#
|
|
15
|
+
# - If NULL is — or could become — a meaningful business state, presence
|
|
16
|
+
# is a business decision: keep it in the model. `email` NULL = a
|
|
17
|
+
# phone-only user; `organization_id` NULL = an unowned template.
|
|
18
|
+
# - If NULL is never a meaningful state by the nature of the data, it is
|
|
19
|
+
# a data-integrity concern and belongs in the schema (see Exception).
|
|
20
|
+
#
|
|
21
|
+
# The line is drawn for standardization and non-subjectivity. Whether a
|
|
22
|
+
# regular column is "required" is subjective and invites per-column
|
|
23
|
+
# debate (`email` looks required until phone signup makes it optional),
|
|
24
|
+
# so the schema should not bake in that debatable call.
|
|
25
|
+
#
|
|
26
|
+
# ❌ Regular column
|
|
27
|
+
# add_column :users, :profile_completion_rate, :float, null: false
|
|
28
|
+
#
|
|
29
|
+
# ✔️ Regular column
|
|
30
|
+
# add_column :users, :profile_completion_rate, :float
|
|
31
|
+
#
|
|
32
|
+
# ## Exception
|
|
33
|
+
# `null: false` IS the right choice where NULL is never a meaningful
|
|
34
|
+
# state:
|
|
35
|
+
#
|
|
36
|
+
# - **Required foreign keys** — NOT flagged: this cop never looks at
|
|
37
|
+
# `belongs_to`, `references`, or `add_reference`. A required FK bundles
|
|
38
|
+
# two things: `foreign_key: true` is pure referential integrity (never
|
|
39
|
+
# a business decision), while `null: false` on the FK is a
|
|
40
|
+
# *mandatory-ness* decision that can flip (a `document` may later be an
|
|
41
|
+
# unowned template). Both are allowed in the schema pragmatically — the
|
|
42
|
+
# referential-integrity guarantee carries the mandatory-ness with it.
|
|
43
|
+
# - **Enum columns** — NULL is outside the enum's domain (a type
|
|
44
|
+
# violation), so `null: false` is required, and enforced from the model
|
|
45
|
+
# side by `DevDoc/Rails/EnumColumnNotNull`. But an enum is a plain
|
|
46
|
+
# `integer` column, statically indistinguishable from any other
|
|
47
|
+
# integer, so THIS cop cannot detect it and WILL flag it. Disable it on
|
|
48
|
+
# the line with a brief reason — `-- enum` — so the migration is
|
|
49
|
+
# self-documenting: a reader sees at a glance that the column is an enum.
|
|
50
|
+
#
|
|
51
|
+
# ✔️ Required foreign key (never flagged)
|
|
52
|
+
# t.belongs_to :user, null: false, foreign_key: true
|
|
53
|
+
#
|
|
54
|
+
# ✔️ Enum (flagged here — disable with a brief `-- enum` reason)
|
|
55
|
+
# # rubocop:disable DevDoc/Migration/AvoidNonNull -- enum
|
|
56
|
+
# add_column :orders, :status, :integer, null: false
|
|
57
|
+
# # rubocop:enable DevDoc/Migration/AvoidNonNull
|
|
58
|
+
#
|
|
59
|
+
# NOTE: This cop is deliberately NOT enum-aware. It could read the
|
|
60
|
+
# model's `enum` declarations and skip those columns, but requiring an
|
|
61
|
+
# explicit per-line disable is intentional: it forces the developer to
|
|
62
|
+
# signal that the column is an enum, which documents the migration. A
|
|
63
|
+
# silent skip would hide that intent.
|
|
64
|
+
#
|
|
65
|
+
# NOTE: This cop only flags `null: false`. It does not flag `null: true`
|
|
66
|
+
# (redundant but harmless), and it does not require foreign keys to carry
|
|
67
|
+
# `null: false` — adding it to an FK is encouraged but not enforced here.
|
|
68
|
+
#
|
|
69
|
+
# @example
|
|
70
|
+
# # bad
|
|
71
|
+
# add_column :users, :name, :string, null: false
|
|
72
|
+
#
|
|
73
|
+
# # bad (enum without a disable — the cop flags it; disable with `-- enum`)
|
|
74
|
+
# t.integer :processing_status, null: false
|
|
75
|
+
#
|
|
76
|
+
# # good
|
|
77
|
+
# add_column :users, :name, :string
|
|
78
|
+
#
|
|
79
|
+
# # good (required foreign key — never flagged)
|
|
80
|
+
# t.belongs_to :user, null: false, foreign_key: true
|
|
81
|
+
class AvoidNonNull < Base
|
|
82
|
+
MSG = 'Avoid `null: false` on regular columns; enforce presence in the model layer. ' \
|
|
83
|
+
'If this is an enum column, disable this cop on the line with a brief reason, e.g. `-- enum`.'.freeze
|
|
84
|
+
|
|
85
|
+
# Column-definition helpers that take a `null:` option. Deliberately
|
|
86
|
+
# EXCLUDES `references` / `belongs_to` (and the separate `add_reference`
|
|
87
|
+
# method): a required foreign key SHOULD carry `null: false`, so those
|
|
88
|
+
# are never flagged.
|
|
89
|
+
COLUMN_METHODS = %i[
|
|
90
|
+
string integer float boolean datetime date text binary decimal
|
|
91
|
+
json jsonb bigint
|
|
92
|
+
].freeze
|
|
93
|
+
|
|
94
|
+
RESTRICT_ON_SEND = (COLUMN_METHODS + %i[add_column]).freeze
|
|
95
|
+
|
|
96
|
+
def_node_matcher :null_false_pair, <<~PATTERN
|
|
97
|
+
(hash <$(pair (sym :null) (false)) ...>)
|
|
98
|
+
PATTERN
|
|
99
|
+
|
|
100
|
+
def on_send(node)
|
|
101
|
+
return unless column_method?(node)
|
|
102
|
+
|
|
103
|
+
options = node.arguments.find(&:hash_type?)
|
|
104
|
+
return unless options
|
|
105
|
+
|
|
106
|
+
pair = null_false_pair(options)
|
|
107
|
+
return unless pair
|
|
108
|
+
|
|
109
|
+
add_offense(pair)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def column_method?(node)
|
|
115
|
+
node.method?(:add_column) || COLUMN_METHODS.include?(node.method_name)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Migration
|
|
5
|
+
# Avoid `create_join_table` — define an explicit join model instead.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# `create_join_table` produces a PK-less table tied to
|
|
9
|
+
# `has_and_belongs_to_many`, which has well-known limitations: no
|
|
10
|
+
# timestamps, no per-row callbacks, and no room for extra columns
|
|
11
|
+
# without migration gymnastics.
|
|
12
|
+
#
|
|
13
|
+
# The preferred Rails idiom is `has_many :through` with an explicit
|
|
14
|
+
# join model, which is just a normal table with a PK and timestamps.
|
|
15
|
+
#
|
|
16
|
+
# ❌
|
|
17
|
+
# create_join_table :products, :categories
|
|
18
|
+
#
|
|
19
|
+
# ✔️ Define an explicit join model
|
|
20
|
+
# create_table :product_categories do |t|
|
|
21
|
+
# t.belongs_to :product, null: false, foreign_key: true
|
|
22
|
+
# t.belongs_to :category, null: false, foreign_key: true
|
|
23
|
+
# t.timestamps
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# # In models:
|
|
27
|
+
# class Product < ApplicationRecord
|
|
28
|
+
# has_many :product_categories
|
|
29
|
+
# has_many :categories, through: :product_categories
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# @example
|
|
33
|
+
# # bad
|
|
34
|
+
# create_join_table :products, :categories
|
|
35
|
+
#
|
|
36
|
+
# # good
|
|
37
|
+
# create_table :product_categories do |t|
|
|
38
|
+
# t.belongs_to :product, null: false, foreign_key: true
|
|
39
|
+
# t.belongs_to :category, null: false, foreign_key: true
|
|
40
|
+
# t.timestamps
|
|
41
|
+
# end
|
|
42
|
+
class NoCreateJoinTable < Base
|
|
43
|
+
MSG = 'Avoid `create_join_table` — define an explicit join model with `has_many :through` instead.'.freeze
|
|
44
|
+
RESTRICT_ON_SEND = %i[create_join_table].freeze
|
|
45
|
+
|
|
46
|
+
def on_send(node)
|
|
47
|
+
add_offense(node.loc.selector)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Migration
|
|
5
|
+
# Every `create_table` migration must have a primary key.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# A table without a primary key is hard to reference, hard to debug,
|
|
9
|
+
# and hard to migrate later. `id: false` is rarely the right answer;
|
|
10
|
+
# `has_many :through` with an explicit join model is preferred for
|
|
11
|
+
# many-to-many relationships, and that join model has its own `id`.
|
|
12
|
+
#
|
|
13
|
+
# ❌
|
|
14
|
+
# create_table :products_categories, id: false do |t|
|
|
15
|
+
# t.belongs_to :product
|
|
16
|
+
# t.belongs_to :category
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# ✔️ Use an explicit join model with has_many :through instead
|
|
20
|
+
# create_table :product_categories do |t|
|
|
21
|
+
# t.belongs_to :product, null: false, foreign_key: true
|
|
22
|
+
# t.belongs_to :category, null: false, foreign_key: true
|
|
23
|
+
# t.timestamps
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# # bad
|
|
28
|
+
# create_table :products_categories, id: false do |t|
|
|
29
|
+
# t.belongs_to :product
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# # good
|
|
33
|
+
# create_table :product_categories do |t|
|
|
34
|
+
# t.belongs_to :product, null: false, foreign_key: true
|
|
35
|
+
# end
|
|
36
|
+
class RequirePrimaryKey < Base
|
|
37
|
+
MSG = 'Every table must have a primary key. Avoid `id: false` — ' \
|
|
38
|
+
'use an explicit join model with `has_many :through` instead.'.freeze
|
|
39
|
+
RESTRICT_ON_SEND = %i[create_table].freeze
|
|
40
|
+
|
|
41
|
+
def_node_matcher :id_false_pair?, <<~PATTERN
|
|
42
|
+
(pair (sym :id) (false))
|
|
43
|
+
PATTERN
|
|
44
|
+
|
|
45
|
+
def on_send(node)
|
|
46
|
+
id_false = node.each_descendant(:pair).find { |pair| id_false_pair?(pair) }
|
|
47
|
+
return unless id_false
|
|
48
|
+
|
|
49
|
+
add_offense(id_false)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -10,6 +10,10 @@ module RuboCop
|
|
|
10
10
|
# auditing, and ordering records, and adding them later is much more
|
|
11
11
|
# painful than including them up front.
|
|
12
12
|
#
|
|
13
|
+
# This rule applies to **every** `create_table` call, including those
|
|
14
|
+
# with `id: false`. If a genuinely timestamp-less table is needed, add
|
|
15
|
+
# an explicit `# rubocop:disable` with a rationale comment.
|
|
16
|
+
#
|
|
13
17
|
# ❌
|
|
14
18
|
# create_table :ai_api_failures do |t|
|
|
15
19
|
# t.belongs_to :ai_chat_message, null: false, foreign_key: true
|
|
@@ -24,9 +28,6 @@ module RuboCop
|
|
|
24
28
|
# t.timestamps
|
|
25
29
|
# end
|
|
26
30
|
#
|
|
27
|
-
# NOTE: This cop skips tables declared with `id: false` (typically join
|
|
28
|
-
# tables), where omitting timestamps is a deliberate choice.
|
|
29
|
-
#
|
|
30
31
|
# @example
|
|
31
32
|
# # bad
|
|
32
33
|
# create_table :users do |t|
|
|
@@ -54,13 +55,7 @@ module RuboCop
|
|
|
54
55
|
...)
|
|
55
56
|
PATTERN
|
|
56
57
|
|
|
57
|
-
def_node_matcher :id_false_option?, <<~PATTERN
|
|
58
|
-
(pair (sym :id) (false))
|
|
59
|
-
PATTERN
|
|
60
|
-
|
|
61
58
|
def on_send(node)
|
|
62
|
-
return if skip_table?(node)
|
|
63
|
-
|
|
64
59
|
block = node.parent
|
|
65
60
|
return unless block&.block_type?
|
|
66
61
|
return if timestamps_present?(block.body)
|
|
@@ -72,10 +67,6 @@ module RuboCop
|
|
|
72
67
|
|
|
73
68
|
private
|
|
74
69
|
|
|
75
|
-
def skip_table?(node)
|
|
76
|
-
node.each_descendant(:pair).any? { |pair| id_false_option?(pair) }
|
|
77
|
-
end
|
|
78
|
-
|
|
79
70
|
def timestamps_present?(body)
|
|
80
71
|
return false if body.nil?
|
|
81
72
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Use `ApplicationRecord.transaction` instead of `SomeModel.transaction` outside model files.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# When using `transaction` outside of a model, `SomeModel.transaction`
|
|
9
|
+
# reads as if there is a meaningful link to that model when there isn't.
|
|
10
|
+
# `ApplicationRecord.transaction` is functionally identical and makes
|
|
11
|
+
# it clear that the transaction has no special relationship to any
|
|
12
|
+
# particular model.
|
|
13
|
+
#
|
|
14
|
+
# ❌ (in a controller or service)
|
|
15
|
+
# Post.transaction do
|
|
16
|
+
# ...
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# ✔️
|
|
20
|
+
# ApplicationRecord.transaction do
|
|
21
|
+
# ...
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# # bad (in a controller or service)
|
|
26
|
+
# Order.transaction do
|
|
27
|
+
# order.save!
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# # good
|
|
31
|
+
# ApplicationRecord.transaction do
|
|
32
|
+
# order.save!
|
|
33
|
+
# end
|
|
34
|
+
class ApplicationRecordTransaction < Base
|
|
35
|
+
extend AutoCorrector
|
|
36
|
+
|
|
37
|
+
ALLOWED_RECEIVERS = %w[ApplicationRecord ActiveRecord::Base].freeze
|
|
38
|
+
|
|
39
|
+
MSG = 'Use `ApplicationRecord.transaction` instead of `%<receiver>s.transaction` outside model files.'.freeze
|
|
40
|
+
RESTRICT_ON_SEND = %i[transaction].freeze
|
|
41
|
+
|
|
42
|
+
def on_send(node)
|
|
43
|
+
receiver = node.receiver
|
|
44
|
+
return if receiver.nil?
|
|
45
|
+
return unless receiver.const_type?
|
|
46
|
+
return if ALLOWED_RECEIVERS.include?(receiver.source)
|
|
47
|
+
|
|
48
|
+
add_offense(receiver, message: format(MSG, receiver: receiver.source)) do |corrector|
|
|
49
|
+
corrector.replace(receiver, 'ApplicationRecord')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|