rubocop-dev_doc 0.1.0 → 0.2.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 +125 -9
- 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_json_column.rb +18 -3
- 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/enum_must_be_symbolized.rb +83 -0
- data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +22 -5
- 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 +14 -9
- data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +91 -0
- data/lib/rubocop-dev_doc.rb +1 -0
- metadata +19 -11
- data/lib/rubocop/cop/dev_doc/migration/avoid_update_column.rb +0 -53
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 17a792c93018068695bb2c73dc023577864a51cbe29fc8962ba0d82f44784bd5
|
|
4
|
+
data.tar.gz: 260d22ceb74db21595f99e912a2bcbc4556718a30de5327fabf562b1174c84aa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6df2aae2a244e306e2a659dfd2f258a5b057fbfa0dda9d6fbc25ef4169fd6c67acc3906e03df78057eb22b4337727c9992ef0bc2b3cd6e760a63edb30dfcfb3b
|
|
7
|
+
data.tar.gz: 05b1392296eb25d9625e3f755f2310a262b955ecef1e38d0448d25e6c99352351c5284a9e61a5d3361415e2c2a91e5ae9589fbea532b077babd856a55eb3fa8c
|
data/config/default.yml
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Default configuration for rubocop-dev_doc cops.
|
|
2
|
-
# Cops are
|
|
2
|
+
# Cops are disabled by default; enable them in your project's .rubocop.yml.
|
|
3
|
+
|
|
4
|
+
# Department-level settings: DocumentationBaseURL lets RuboCop construct per-cop
|
|
5
|
+
# URLs automatically. DocumentationExtension matches the generated file format.
|
|
6
|
+
DevDoc/Migration:
|
|
7
|
+
DocumentationBaseURL: https://github.com/hgani/dev-doc/blob/main/docs/cops
|
|
8
|
+
DocumentationExtension: '.md'
|
|
9
|
+
|
|
10
|
+
DevDoc/Rails:
|
|
11
|
+
DocumentationBaseURL: https://github.com/hgani/dev-doc/blob/main/docs/cops
|
|
12
|
+
DocumentationExtension: '.md'
|
|
13
|
+
|
|
14
|
+
DevDoc/Route:
|
|
15
|
+
DocumentationBaseURL: https://github.com/hgani/dev-doc/blob/main/docs/cops
|
|
16
|
+
DocumentationExtension: '.md'
|
|
17
|
+
|
|
18
|
+
DevDoc/Style:
|
|
19
|
+
DocumentationBaseURL: https://github.com/hgani/dev-doc/blob/main/docs/cops
|
|
20
|
+
DocumentationExtension: '.md'
|
|
3
21
|
|
|
4
22
|
DevDoc/Migration/AvoidJsonColumn:
|
|
5
23
|
Description: 'Use `jsonb` instead of `json` for column types.'
|
|
@@ -10,7 +28,7 @@ DevDoc/Migration/AvoidJsonColumn:
|
|
|
10
28
|
|
|
11
29
|
DevDoc/Migration/RequireTimestamps:
|
|
12
30
|
Description: 'Always include `t.timestamps` in every `create_table` migration.'
|
|
13
|
-
Enabled:
|
|
31
|
+
Enabled: false
|
|
14
32
|
Include:
|
|
15
33
|
- 'db/migrate/*.rb'
|
|
16
34
|
- 'db/migrate/**/*.rb'
|
|
@@ -29,12 +47,14 @@ DevDoc/Migration/AvoidColumnDefault:
|
|
|
29
47
|
- 'db/migrate/*.rb'
|
|
30
48
|
- 'db/migrate/**/*.rb'
|
|
31
49
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
50
|
+
# Intentionally global (no Include) — these patterns are risky in any file, not only migrations.
|
|
51
|
+
# Add project-specific Exclude entries (e.g. db/seeds.rb, lib/tasks/**/*.rb) in your .rubocop.yml
|
|
52
|
+
# for places where bulk operations are intentional and performance-critical.
|
|
53
|
+
DevDoc/Migration/AvoidBypassingValidation:
|
|
54
|
+
Description: 'Avoid methods that bypass validations and callbacks (`update_column`, `update_all`, `insert_all`, etc.).'
|
|
55
|
+
Enabled: false
|
|
56
|
+
Exclude:
|
|
57
|
+
- 'spec/**/*'
|
|
38
58
|
|
|
39
59
|
DevDoc/Migration/DateColumnNaming:
|
|
40
60
|
Description: 'Date columns should end with `_on`; datetime columns should end with `_at`.'
|
|
@@ -56,6 +76,9 @@ DevDoc/Migration/AvoidVagueColumnNames:
|
|
|
56
76
|
DevDoc/Route/ResourcesRequireOnly:
|
|
57
77
|
Description: 'Always use `only:` or `except:` when defining `resources` or `resource` routes.'
|
|
58
78
|
Enabled: true
|
|
79
|
+
# RequireOnly: true (default) — only `only:` is accepted; `except:` is flagged.
|
|
80
|
+
# RequireOnly: false — both `only:` and `except:` are accepted; only the bare form is flagged.
|
|
81
|
+
RequireOnly: true
|
|
59
82
|
Include:
|
|
60
83
|
- 'config/routes.rb'
|
|
61
84
|
- 'config/routes/**/*.rb'
|
|
@@ -63,6 +86,11 @@ DevDoc/Route/ResourcesRequireOnly:
|
|
|
63
86
|
DevDoc/Rails/NoDeliverLaterInTransaction:
|
|
64
87
|
Description: 'Avoid `deliver_later`/`perform_later` inside a `transaction` block; the job may use stale data.'
|
|
65
88
|
Enabled: true
|
|
89
|
+
KnownAsyncWrappers:
|
|
90
|
+
- send_verification_email!
|
|
91
|
+
- send_reset_password_instructions
|
|
92
|
+
- send_confirmation_instructions
|
|
93
|
+
- send_unlock_instructions
|
|
66
94
|
|
|
67
95
|
DevDoc/Rails/NoPerformLaterInModel:
|
|
68
96
|
Description: 'Avoid `perform_later` inside model files; use explicit methods called from the controller.'
|
|
@@ -70,12 +98,100 @@ DevDoc/Rails/NoPerformLaterInModel:
|
|
|
70
98
|
Include:
|
|
71
99
|
- 'app/models/**/*.rb'
|
|
72
100
|
|
|
101
|
+
DevDoc/Rails/EnumMustBeSymbolized:
|
|
102
|
+
Description: 'Pair every `enum :foo` with `enum_symbolize :foo` so the reader returns a symbol.'
|
|
103
|
+
Enabled: true
|
|
104
|
+
Include:
|
|
105
|
+
- 'app/models/**/*.rb'
|
|
106
|
+
|
|
73
107
|
DevDoc/Style/AvoidSend:
|
|
74
108
|
Description: 'Avoid `send`/`public_send` with an explicit receiver; prefer direct calls or safer alternatives.'
|
|
75
109
|
Enabled: true
|
|
76
110
|
|
|
77
111
|
DevDoc/Style/AvoidHeadResponse:
|
|
78
|
-
Description: 'Avoid `head()`
|
|
112
|
+
Description: 'Avoid `head()` with error statuses; delegate error handling to Rails exceptions or model validations.'
|
|
79
113
|
Enabled: true
|
|
80
114
|
Include:
|
|
81
115
|
- 'app/controllers/**/*.rb'
|
|
116
|
+
FlaggedStatuses:
|
|
117
|
+
- not_found
|
|
118
|
+
- unprocessable_entity
|
|
119
|
+
- forbidden
|
|
120
|
+
- unauthorized
|
|
121
|
+
- bad_request
|
|
122
|
+
- conflict
|
|
123
|
+
- gone
|
|
124
|
+
- method_not_allowed
|
|
125
|
+
- '404'
|
|
126
|
+
- '422'
|
|
127
|
+
- '403'
|
|
128
|
+
- '401'
|
|
129
|
+
- '400'
|
|
130
|
+
- '409'
|
|
131
|
+
- '410'
|
|
132
|
+
- '405'
|
|
133
|
+
|
|
134
|
+
DevDoc/Migration/RequirePrimaryKey:
|
|
135
|
+
Description: 'Every `create_table` must have a primary key. Avoid `id: false`.'
|
|
136
|
+
Enabled: true
|
|
137
|
+
Include:
|
|
138
|
+
- 'db/migrate/*.rb'
|
|
139
|
+
- 'db/migrate/**/*.rb'
|
|
140
|
+
|
|
141
|
+
DevDoc/Migration/NoCreateJoinTable:
|
|
142
|
+
Description: 'Avoid `create_join_table` — define an explicit join model with `has_many :through` instead.'
|
|
143
|
+
Enabled: true
|
|
144
|
+
Include:
|
|
145
|
+
- 'db/migrate/*.rb'
|
|
146
|
+
- 'db/migrate/**/*.rb'
|
|
147
|
+
|
|
148
|
+
# This cop is heuristic: it matches column names whose last segment is a known
|
|
149
|
+
# monetary word. Enable it in your project's .rubocop.yml and extend MonetaryNames
|
|
150
|
+
# if your domain uses different names. Disabled by default to avoid false positives.
|
|
151
|
+
DevDoc/Migration/AmountColumnInCents:
|
|
152
|
+
Description: 'Monetary columns must be stored as integer cents with an `_in_cents` suffix.'
|
|
153
|
+
Enabled: false
|
|
154
|
+
MonetaryNames:
|
|
155
|
+
- amount
|
|
156
|
+
- price
|
|
157
|
+
- balance
|
|
158
|
+
- cost
|
|
159
|
+
- fee
|
|
160
|
+
- total
|
|
161
|
+
- subtotal
|
|
162
|
+
- discount
|
|
163
|
+
- tax
|
|
164
|
+
Include:
|
|
165
|
+
- 'db/migrate/*.rb'
|
|
166
|
+
- 'db/migrate/**/*.rb'
|
|
167
|
+
|
|
168
|
+
DevDoc/Rails/AvoidRailsCallbacks:
|
|
169
|
+
Description: 'Avoid Rails callback DSL (`after_create`, `before_save`, etc.) — use explicit methods instead.'
|
|
170
|
+
Enabled: true
|
|
171
|
+
Include:
|
|
172
|
+
- 'app/models/**/*.rb'
|
|
173
|
+
|
|
174
|
+
DevDoc/Rails/ApplicationRecordTransaction:
|
|
175
|
+
Description: 'Use `ApplicationRecord.transaction` instead of `SomeModel.transaction` outside model files.'
|
|
176
|
+
Enabled: true
|
|
177
|
+
Exclude:
|
|
178
|
+
- 'app/models/**/*.rb'
|
|
179
|
+
|
|
180
|
+
DevDoc/Style/AvoidOptionsHash:
|
|
181
|
+
Description: 'Use keyword arguments instead of `**options` — typos raise `ArgumentError`; options hashes swallow them silently.'
|
|
182
|
+
Enabled: true
|
|
183
|
+
|
|
184
|
+
DevDoc/Style/StringSymbolComparison:
|
|
185
|
+
Description: 'Comparing a known-string source (params, request.headers, ENV) to a symbol literal is always false.'
|
|
186
|
+
Enabled: true
|
|
187
|
+
|
|
188
|
+
Rails/CreateTableWithTimestamps:
|
|
189
|
+
Enabled: true
|
|
190
|
+
Include:
|
|
191
|
+
- 'db/migrate/*.rb'
|
|
192
|
+
- 'db/migrate/**/*.rb'
|
|
193
|
+
|
|
194
|
+
Rails/SkipsModelValidations:
|
|
195
|
+
Enabled: true
|
|
196
|
+
Exclude:
|
|
197
|
+
- 'spec/**/*'
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Migration
|
|
5
|
+
# Monetary columns must be stored as integer cents with an `_in_cents` suffix.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# Storing monetary values as floats or decimals introduces floating-point
|
|
9
|
+
# precision issues. The safe approach is to store values as integer cents
|
|
10
|
+
# and convert to dollars only in user-facing forms and displays.
|
|
11
|
+
#
|
|
12
|
+
# This cop is heuristic: it matches column names whose last segment is a
|
|
13
|
+
# known monetary word (configurable via `MonetaryNames`). Extend the list
|
|
14
|
+
# in your `.rubocop.yml` if your domain uses different names.
|
|
15
|
+
#
|
|
16
|
+
# ❌
|
|
17
|
+
# t.float :amount
|
|
18
|
+
# t.decimal :price
|
|
19
|
+
# add_column :orders, :total, :decimal
|
|
20
|
+
#
|
|
21
|
+
# ✔️
|
|
22
|
+
# t.integer :amount_in_cents
|
|
23
|
+
# t.integer :price_in_cents
|
|
24
|
+
# add_column :orders, :total_in_cents, :integer
|
|
25
|
+
#
|
|
26
|
+
# To extend the monetary names list:
|
|
27
|
+
#
|
|
28
|
+
# DevDoc/Migration/AmountColumnInCents:
|
|
29
|
+
# Enabled: true
|
|
30
|
+
# MonetaryNames:
|
|
31
|
+
# - amount
|
|
32
|
+
# - price
|
|
33
|
+
# - balance
|
|
34
|
+
# - cost
|
|
35
|
+
# - fee
|
|
36
|
+
# - total
|
|
37
|
+
# - subtotal
|
|
38
|
+
# - discount
|
|
39
|
+
# - tax
|
|
40
|
+
# - revenue # custom addition
|
|
41
|
+
#
|
|
42
|
+
# @example
|
|
43
|
+
# # bad
|
|
44
|
+
# t.float :amount
|
|
45
|
+
# t.decimal :price
|
|
46
|
+
# add_column :orders, :total, :decimal
|
|
47
|
+
#
|
|
48
|
+
# # good
|
|
49
|
+
# t.integer :amount_in_cents
|
|
50
|
+
# t.integer :price_in_cents
|
|
51
|
+
# add_column :orders, :total_in_cents, :integer
|
|
52
|
+
class AmountColumnInCents < Base
|
|
53
|
+
DEFAULT_MONETARY_NAMES = %w[amount price balance cost fee total subtotal discount tax].freeze
|
|
54
|
+
|
|
55
|
+
MSG = 'Store monetary values as integer cents — ' \
|
|
56
|
+
'rename `%<name>s` to `%<name>s_in_cents` and use `t.integer`.'.freeze
|
|
57
|
+
|
|
58
|
+
COLUMN_METHODS = %i[float decimal integer].freeze
|
|
59
|
+
|
|
60
|
+
def on_send(node)
|
|
61
|
+
col_name_node = column_name_node(node)
|
|
62
|
+
return unless col_name_node&.sym_type?
|
|
63
|
+
|
|
64
|
+
col_name = col_name_node.value.to_s
|
|
65
|
+
return if col_name.end_with?('_in_cents')
|
|
66
|
+
return unless monetary_suffix?(col_name)
|
|
67
|
+
|
|
68
|
+
add_offense(col_name_node, message: format(MSG, name: col_name))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def column_name_node(node)
|
|
74
|
+
if node.method?(:add_column)
|
|
75
|
+
node.arguments[1]
|
|
76
|
+
elsif COLUMN_METHODS.include?(node.method_name)
|
|
77
|
+
node.first_argument
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def monetary_suffix?(name)
|
|
82
|
+
monetary_names.include?(name.split('_').last)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def monetary_names
|
|
86
|
+
cop_config.fetch('MonetaryNames', DEFAULT_MONETARY_NAMES)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Migration
|
|
5
|
+
# Avoid methods that bypass validations and callbacks.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# Avoid bypassing validation unless absolutely necessary. Methods like
|
|
9
|
+
# `update_column`, `update_all`, `insert_all`, `upsert_all`, `delete_all`,
|
|
10
|
+
# and `save(validate: false)` skip validations and callbacks, which hides
|
|
11
|
+
# data integrity issues rather than surfacing them.
|
|
12
|
+
#
|
|
13
|
+
# Even in migrations, check the code to see if there is any blatant
|
|
14
|
+
# reason why existing records may be invalid. If there is, fix those
|
|
15
|
+
# records first rather than bypassing validation.
|
|
16
|
+
#
|
|
17
|
+
# ❌ Bypasses validation — hides data integrity issues
|
|
18
|
+
# Faq.where(purpose: nil).update_all(purpose: :intro)
|
|
19
|
+
#
|
|
20
|
+
# ✔️ Runs validation — surfaces problems early
|
|
21
|
+
# Faq.where(purpose: nil).find_each do |faq|
|
|
22
|
+
# faq.purpose = :intro
|
|
23
|
+
# faq.save!
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# # bad
|
|
28
|
+
# Faq.where(purpose: nil).update_all(purpose: :intro)
|
|
29
|
+
#
|
|
30
|
+
# # bad
|
|
31
|
+
# user.update_column(:status, 'active')
|
|
32
|
+
#
|
|
33
|
+
# # bad
|
|
34
|
+
# user.save(validate: false)
|
|
35
|
+
#
|
|
36
|
+
# # bad
|
|
37
|
+
# User.insert_all(rows)
|
|
38
|
+
#
|
|
39
|
+
# # good
|
|
40
|
+
# Faq.where(purpose: nil).find_each do |faq|
|
|
41
|
+
# faq.purpose = :intro
|
|
42
|
+
# faq.save!
|
|
43
|
+
# end
|
|
44
|
+
class AvoidBypassingValidation < Base
|
|
45
|
+
MESSAGES = {
|
|
46
|
+
update_column: 'Avoid `update_column`; it bypasses validations. Use `save!` instead.',
|
|
47
|
+
update_columns: 'Avoid `update_columns`; it bypasses validations. Use `save!` instead.',
|
|
48
|
+
update_all: 'Avoid `update_all`; it bypasses validations. Use `save!` in a loop instead.',
|
|
49
|
+
insert_all: 'Avoid `insert_all`; it bypasses validations. Use `create!` in a loop, ' \
|
|
50
|
+
'or `# rubocop:disable` with a reason if bulk-insert is intentional.',
|
|
51
|
+
upsert_all: 'Avoid `upsert_all`; it bypasses validations. Use `create!`/`update!` in a loop, ' \
|
|
52
|
+
'or `# rubocop:disable` with a reason if bulk-upsert is intentional.',
|
|
53
|
+
delete_all: 'Avoid `delete_all`; it bypasses callbacks. Use `destroy_all` to run callbacks, ' \
|
|
54
|
+
'or `# rubocop:disable` with a reason if bulk-delete is intentional.'
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
57
|
+
SAVE_MSG = 'Avoid `save(validate: false)`; it bypasses validations. Use `save!` instead.'.freeze
|
|
58
|
+
|
|
59
|
+
RESTRICT_ON_SEND = %i[update_column update_columns update_all insert_all upsert_all delete_all save].freeze
|
|
60
|
+
|
|
61
|
+
def on_send(node)
|
|
62
|
+
if node.method?(:save)
|
|
63
|
+
return unless save_with_validate_false?(node)
|
|
64
|
+
|
|
65
|
+
add_offense(node.loc.selector, message: SAVE_MSG)
|
|
66
|
+
else
|
|
67
|
+
add_offense(node.loc.selector, message: MESSAGES[node.method_name])
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def save_with_validate_false?(node)
|
|
74
|
+
node.arguments.any? do |arg|
|
|
75
|
+
next unless arg.hash_type?
|
|
76
|
+
|
|
77
|
+
arg.pairs.any? do |pair|
|
|
78
|
+
pair.key.sym_type? && pair.key.value == :validate && pair.value.false_type?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -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
|
|
@@ -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,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
|
|