active_record_doctor 1.11.0 → 1.13.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/README.md +41 -14
- data/lib/active_record_doctor/config/loader.rb +1 -1
- data/lib/active_record_doctor/detectors/base.rb +30 -15
- data/lib/active_record_doctor/detectors/extraneous_indexes.rb +14 -9
- data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +1 -1
- data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +44 -31
- data/lib/active_record_doctor/detectors/mismatched_foreign_key_type.rb +1 -1
- data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +2 -2
- data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +73 -23
- data/lib/active_record_doctor/detectors/short_primary_key_type.rb +3 -3
- data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +34 -7
- data/lib/active_record_doctor/logger/hierarchical.rb +1 -1
- data/lib/active_record_doctor/railtie.rb +1 -1
- data/lib/active_record_doctor/runner.rb +1 -1
- data/lib/active_record_doctor/utils.rb +21 -0
- data/lib/active_record_doctor/version.rb +1 -1
- data/lib/active_record_doctor.rb +2 -0
- data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +14 -14
- data/lib/tasks/active_record_doctor.rake +2 -2
- metadata +11 -47
- data/test/active_record_doctor/config/loader_test.rb +0 -120
- data/test/active_record_doctor/config_test.rb +0 -116
- data/test/active_record_doctor/detectors/disable_test.rb +0 -30
- data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +0 -224
- data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +0 -79
- data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +0 -472
- data/test/active_record_doctor/detectors/incorrect_length_validation_test.rb +0 -107
- data/test/active_record_doctor/detectors/mismatched_foreign_key_type_test.rb +0 -116
- data/test/active_record_doctor/detectors/missing_foreign_keys_test.rb +0 -70
- data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +0 -273
- data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +0 -232
- data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +0 -327
- data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +0 -72
- data/test/active_record_doctor/detectors/undefined_table_references_test.rb +0 -55
- data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +0 -177
- data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +0 -78
- data/test/active_record_doctor/runner_test.rb +0 -41
- data/test/generators/active_record_doctor/add_indexes/add_indexes_generator_test.rb +0 -131
- data/test/setup.rb +0 -124
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cc970595479a84692995f75b3e512f1b1a01dc6fc61eee9a738abbd402ac9e44
|
4
|
+
data.tar.gz: 3bd5807bc126459379478c24d14a5595a2ee207415cb71c86c82ebdfae5f19f3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4b0ac66a6f9037ce810ff73660735b816bc58d614dd7612b7c7849292741519cbecd6bdb7105c7392e01fc1621ce550e1b8ba0726573beca33ee6e10ec6e35da
|
7
|
+
data.tar.gz: c3d2d7b0448ccff98001c72fa6330b54f45364a43e3eff35ff7407d0e2aeaaf8c3070a3ce308992577fa41adf7cdde3a8ba2aa93db4c8955fd69e5ec7f29d03b
|
data/README.md
CHANGED
@@ -7,7 +7,7 @@ can detect:
|
|
7
7
|
* unindexed `deleted_at` columns - [`active_record_doctor:unindexed_deleted_at`](#detecting-unindexed-deleted_at-columns)
|
8
8
|
* missing foreign key constraints - [`active_record_doctor:missing_foreign_keys`](#detecting-missing-foreign-key-constraints)
|
9
9
|
* models referencing undefined tables - [`active_record_doctor:undefined_table_references`](#detecting-models-referencing-undefined-tables)
|
10
|
-
* uniqueness validations not backed by
|
10
|
+
* uniqueness validations not backed by a unique index - [`active_record_doctor:missing_unique_indexes`](#detecting-uniqueness-validations-not-backed-by-an-index)
|
11
11
|
* missing non-`NULL` constraints - [`active_record_doctor:missing_non_null_constraint`](#detecting-missing-non-null-constraints)
|
12
12
|
* missing presence validations - [`active_record_doctor:missing_presence_validation`](#detecting-missing-presence-validations)
|
13
13
|
* incorrect presence validations on boolean columns - [`active_record_doctor:incorrect_boolean_presence_validation`](#detecting-incorrect-presence-validations-on-boolean-columns)
|
@@ -144,6 +144,33 @@ as extraneous.
|
|
144
144
|
Configuration options for each detector are listed below. They can also be
|
145
145
|
obtained via the help mechanism described in the previous section.
|
146
146
|
|
147
|
+
### Regexp-Based Ignores
|
148
|
+
|
149
|
+
Settings like `ignore_tables`, `ignore_indexes`, and so on accept list of
|
150
|
+
identifiers to ignore. These can be either:
|
151
|
+
|
152
|
+
1. Strings - in which case an exact match is needed.
|
153
|
+
2. Regexps - which are matched against object names, and matching ones are
|
154
|
+
excluded from output.
|
155
|
+
|
156
|
+
For example, to ignore all tables starting with `legacy_` you can write:
|
157
|
+
|
158
|
+
```ruby
|
159
|
+
ActiveRecordDoctor.configure do
|
160
|
+
global :ignore_tables, [
|
161
|
+
# Ignore internal Rails-related tables.
|
162
|
+
"ar_internal_metadata",
|
163
|
+
"schema_migrations",
|
164
|
+
"active_storage_blobs",
|
165
|
+
"active_storage_attachments",
|
166
|
+
"action_text_rich_texts",
|
167
|
+
|
168
|
+
# Ignore all legacy tables.
|
169
|
+
/^legacy_/
|
170
|
+
]
|
171
|
+
end
|
172
|
+
```
|
173
|
+
|
147
174
|
### Indexing Unindexed Foreign Keys
|
148
175
|
|
149
176
|
Foreign keys should be indexed unless it's proven ineffective. However, Rails
|
@@ -158,7 +185,7 @@ three-step process:
|
|
158
185
|
```
|
159
186
|
|
160
187
|
2. Remove columns that should _not_ be indexed from `unindexed_foreign_keys.txt`
|
161
|
-
as a column can look like a foreign key (i.e.
|
188
|
+
as a column can look like a foreign key (i.e. ending with `_id`) without being
|
162
189
|
one.
|
163
190
|
|
164
191
|
3. Generate the migrations
|
@@ -209,7 +236,7 @@ To discover such indexes automatically just follow these steps:
|
|
209
236
|
|
210
237
|
3. Create a migration to drop the indexes.
|
211
238
|
|
212
|
-
The indexes aren't dropped automatically because there
|
239
|
+
The indexes aren't dropped automatically because there are usually just a few of
|
213
240
|
them and it's a good idea to double-check that you won't drop something
|
214
241
|
necessary.
|
215
242
|
|
@@ -265,11 +292,11 @@ Supported configuration options:
|
|
265
292
|
If `users.profile_id` references a row in `profiles` then this can be expressed
|
266
293
|
at the database level with a foreign key constraint. It _forces_
|
267
294
|
`users.profile_id` to point to an existing row in `profiles`. The problem is
|
268
|
-
that in many legacy Rails apps the constraint isn't enforced at the database
|
295
|
+
that in many legacy Rails apps, the constraint isn't enforced at the database
|
269
296
|
level.
|
270
297
|
|
271
298
|
`active_record_doctor` can automatically detect foreign keys that could benefit
|
272
|
-
from a foreign key constraint (a future version will generate a
|
299
|
+
from a foreign key constraint (a future version will generate a migration that
|
273
300
|
add the constraint; for now, it's your job). You can obtain the list of foreign
|
274
301
|
keys with the following command:
|
275
302
|
|
@@ -307,7 +334,7 @@ before they hit production.
|
|
307
334
|
* Rails 5+ and _any_ database or
|
308
335
|
* Rails 4.2 with PostgreSQL.
|
309
336
|
|
310
|
-
The only
|
337
|
+
The only thing you need to do is run:
|
311
338
|
|
312
339
|
```
|
313
340
|
bundle exec rake active_record_doctor:undefined_table_references
|
@@ -320,7 +347,7 @@ this:
|
|
320
347
|
Contract references a non-existent table or view named contract_records
|
321
348
|
```
|
322
349
|
|
323
|
-
On top of that `rake` will exit with status code of 1. This allows you to use
|
350
|
+
On top of that `rake` will exit with a status code of 1. This allows you to use
|
324
351
|
this check as part of your Continuous Integration pipeline.
|
325
352
|
|
326
353
|
Supported configuration options:
|
@@ -333,7 +360,7 @@ Supported configuration options:
|
|
333
360
|
|
334
361
|
Model-level uniqueness validations and `has_one` associations should be backed
|
335
362
|
by a database index in order to be robust. Otherwise you risk inserting
|
336
|
-
duplicate values under heavy load.
|
363
|
+
duplicate values under a heavy load.
|
337
364
|
|
338
365
|
In order to detect such validations run:
|
339
366
|
|
@@ -475,7 +502,7 @@ Supported configuration options:
|
|
475
502
|
|
476
503
|
- `enabled` - set to `false` to disable the detector altogether
|
477
504
|
- `ignore_models` - models whose validators should not be checked.
|
478
|
-
- `
|
505
|
+
- `ignore_attributes` - attributes, written as Model.attribute, whose validators
|
479
506
|
should not be checked.
|
480
507
|
|
481
508
|
### Detecting Incorrect `dependent` Option on Associations
|
@@ -489,7 +516,7 @@ This can lead to two types of errors:
|
|
489
516
|
- Using `delete_all` when dependent models define callbacks - they will NOT be
|
490
517
|
invoked.
|
491
518
|
- Using `destroy` when dependent models define no callbacks - dependent models
|
492
|
-
will be loaded one
|
519
|
+
will be loaded one by one with no reason
|
493
520
|
|
494
521
|
In order to detect associations affected by the two aforementioned problems run
|
495
522
|
the following command:
|
@@ -530,8 +557,8 @@ The output of the command looks like this:
|
|
530
557
|
change the type of companies.id to bigint
|
531
558
|
```
|
532
559
|
|
533
|
-
The above means `
|
534
|
-
example migration to accomplish this looks
|
560
|
+
The above means `companies.id` should be migrated to a wider integer type. An
|
561
|
+
example migration to accomplish this looks like this:
|
535
562
|
|
536
563
|
```ruby
|
537
564
|
class ChangeCompaniesPrimaryKeyType < ActiveRecord::Migration[5.1]
|
@@ -565,7 +592,7 @@ bundle exec rake active_record_doctor:mismatched_foreign_key_type
|
|
565
592
|
The output of the command looks like this:
|
566
593
|
|
567
594
|
```
|
568
|
-
companies.user_id references a column of different type - foreign keys should be of the same type as the referenced column
|
595
|
+
companies.user_id references a column of a different type - foreign keys should be of the same type as the referenced column
|
569
596
|
```
|
570
597
|
|
571
598
|
Supported configuration options:
|
@@ -584,7 +611,7 @@ combinations of Ruby and Rails versions. Specifically:
|
|
584
611
|
supported by `active_record_doctor`.
|
585
612
|
2. If a Ruby version is compatible with a supported Rails version then it's
|
586
613
|
also supported by `active_record_doctor`.
|
587
|
-
3. Only most recent teeny Ruby versions and patch Rails versions are supported.
|
614
|
+
3. Only the most recent teeny Ruby versions and patch Rails versions are supported.
|
588
615
|
|
589
616
|
## Author
|
590
617
|
|
@@ -27,7 +27,7 @@ module ActiveRecordDoctor # :nodoc:
|
|
27
27
|
end
|
28
28
|
|
29
29
|
# The same global can be used by multiple detectors so we must remove
|
30
|
-
# duplicates to ensure they aren't reported
|
30
|
+
# duplicates to ensure they aren't reported multiple times via the user
|
31
31
|
# interface (e.g. in error messages).
|
32
32
|
recognized_globals.uniq!
|
33
33
|
|
@@ -139,7 +139,7 @@ module ActiveRecordDoctor
|
|
139
139
|
# ActiveRecord 6.1+
|
140
140
|
if connection.respond_to?(:supports_check_constraints?) && connection.supports_check_constraints?
|
141
141
|
connection.check_constraints(table_name).select(&:validated?).map(&:expression)
|
142
|
-
elsif postgresql?
|
142
|
+
elsif Utils.postgresql?(connection)
|
143
143
|
definitions =
|
144
144
|
connection.select_values(<<-SQL)
|
145
145
|
SELECT pg_get_constraintdef(oid, true)
|
@@ -164,17 +164,13 @@ module ActiveRecordDoctor
|
|
164
164
|
self.class.underscored_name
|
165
165
|
end
|
166
166
|
|
167
|
-
def postgresql?
|
168
|
-
["PostgreSQL", "PostGIS"].include?(connection.adapter_name)
|
169
|
-
end
|
170
|
-
|
171
167
|
def each_model(except: [], abstract: nil, existing_tables_only: false)
|
172
168
|
log("Iterating over Active Record models") do
|
173
169
|
models.each do |model|
|
174
170
|
case
|
175
171
|
when model.name.start_with?("HABTM_")
|
176
172
|
log("#{model.name} - has-belongs-to-many model; skipping")
|
177
|
-
when
|
173
|
+
when ignored?(model.name, except)
|
178
174
|
log("#{model.name} - ignored via the configuration; skipping")
|
179
175
|
when abstract && !model.abstract_class?
|
180
176
|
log("#{model.name} - non-abstract model; skipping")
|
@@ -204,7 +200,7 @@ module ActiveRecordDoctor
|
|
204
200
|
log(message) do
|
205
201
|
indexes.each do |index|
|
206
202
|
case
|
207
|
-
when
|
203
|
+
when ignored?(index.name, except)
|
208
204
|
log("#{index.name} - ignored via the configuration; skipping")
|
209
205
|
when multicolumn_only && !index.columns.is_a?(Array)
|
210
206
|
log("#{index.name} - single-column index; skipping")
|
@@ -221,7 +217,7 @@ module ActiveRecordDoctor
|
|
221
217
|
log("Iterating over attributes of #{model.name}") do
|
222
218
|
connection.columns(model.table_name).each do |column|
|
223
219
|
case
|
224
|
-
when
|
220
|
+
when ignored?("#{model.name}.#{column.name}", except)
|
225
221
|
log("#{model.name}.#{column.name} - ignored via the configuration; skipping")
|
226
222
|
when type && !Array(type).include?(column.type)
|
227
223
|
log("#{model.name}.#{column.name} - ignored due to the #{column.type} type; skipping")
|
@@ -238,7 +234,7 @@ module ActiveRecordDoctor
|
|
238
234
|
log("Iterating over columns of #{table_name}") do
|
239
235
|
connection.columns(table_name).each do |column|
|
240
236
|
case
|
241
|
-
when
|
237
|
+
when ignored?("#{table_name}.#{column.name}", except)
|
242
238
|
log("#{column.name} - ignored via the configuration; skipping")
|
243
239
|
when only.nil? || only.include?(column.name)
|
244
240
|
log(column.name.to_s) do
|
@@ -270,7 +266,7 @@ module ActiveRecordDoctor
|
|
270
266
|
log("Iterating over tables") do
|
271
267
|
tables.each do |table|
|
272
268
|
case
|
273
|
-
when
|
269
|
+
when ignored?(table, except)
|
274
270
|
log("#{table} - ignored via the configuration; skipping")
|
275
271
|
else
|
276
272
|
log(table) do
|
@@ -281,18 +277,33 @@ module ActiveRecordDoctor
|
|
281
277
|
end
|
282
278
|
end
|
283
279
|
|
280
|
+
def each_data_source(except: [])
|
281
|
+
log("Iterating over data sources") do
|
282
|
+
connection.data_sources.each do |data_source|
|
283
|
+
if ignored?(data_source, except)
|
284
|
+
log("#{data_source} - ignored via the configuration; skipping")
|
285
|
+
else
|
286
|
+
log(data_source) do
|
287
|
+
yield(data_source)
|
288
|
+
end
|
289
|
+
end
|
290
|
+
end
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
284
294
|
def each_association(model, except: [], type: [:has_many, :has_one, :belongs_to], has_scope: nil, through: nil)
|
285
295
|
type = Array(type)
|
286
296
|
|
287
297
|
log("Iterating over associations on #{model.name}") do
|
288
|
-
associations =
|
289
|
-
|
290
|
-
|
291
|
-
|
298
|
+
associations = type.map do |type1|
|
299
|
+
# Skip inherited associations from STI to prevent them
|
300
|
+
# from being reported multiple times on subclasses.
|
301
|
+
model.reflect_on_all_associations(type1) - model.superclass.reflect_on_all_associations(type1)
|
302
|
+
end.flatten
|
292
303
|
|
293
304
|
associations.each do |association|
|
294
305
|
case
|
295
|
-
when
|
306
|
+
when ignored?("#{model.name}.#{association.name}", except)
|
296
307
|
log("#{model.name}.#{association.name} - ignored via the configuration; skipping")
|
297
308
|
when through && !association.is_a?(ActiveRecord::Reflection::ThroughReflection)
|
298
309
|
log("#{model.name}.#{association.name} - is not a through association; skipping")
|
@@ -310,6 +321,10 @@ module ActiveRecordDoctor
|
|
310
321
|
end
|
311
322
|
end
|
312
323
|
end
|
324
|
+
|
325
|
+
def ignored?(name, patterns)
|
326
|
+
patterns.any? { |pattern| pattern === name } # rubocop:disable Style/CaseEquality
|
327
|
+
end
|
313
328
|
end
|
314
329
|
end
|
315
330
|
end
|
@@ -19,11 +19,13 @@ module ActiveRecordDoctor
|
|
19
19
|
|
20
20
|
private
|
21
21
|
|
22
|
-
def message(extraneous_index:, replacement_indexes:)
|
22
|
+
def message(table:, extraneous_index:, replacement_indexes:)
|
23
23
|
if replacement_indexes.nil?
|
24
|
-
"remove #{extraneous_index} - coincides with the primary key on the table"
|
24
|
+
"remove #{extraneous_index} from #{table} - coincides with the primary key on the table"
|
25
25
|
else
|
26
|
-
|
26
|
+
# rubocop:disable Layout/LineLength
|
27
|
+
"remove the index #{extraneous_index} from the table #{table} - queries should be able to use the following #{'index'.pluralize(replacement_indexes.count)} instead: #{replacement_indexes.join(' or ')}"
|
28
|
+
# rubocop:enable Layout/LineLength
|
27
29
|
end
|
28
30
|
end
|
29
31
|
|
@@ -34,7 +36,7 @@ module ActiveRecordDoctor
|
|
34
36
|
|
35
37
|
def subindexes_of_multi_column_indexes
|
36
38
|
log(__method__) do
|
37
|
-
|
39
|
+
each_data_source(except: config(:ignore_tables)) do |table|
|
38
40
|
each_index(table, except: config(:ignore_indexes), multicolumn_only: true) do |index, indexes|
|
39
41
|
replacement_indexes = indexes.select do |other_index|
|
40
42
|
index != other_index && replaceable_with?(index, other_index)
|
@@ -46,6 +48,7 @@ module ActiveRecordDoctor
|
|
46
48
|
end
|
47
49
|
|
48
50
|
problem!(
|
51
|
+
table: table,
|
49
52
|
extraneous_index: index.name,
|
50
53
|
replacement_indexes: replacement_indexes.map(&:name).sort
|
51
54
|
)
|
@@ -60,7 +63,7 @@ module ActiveRecordDoctor
|
|
60
63
|
each_index(table, except: config(:ignore_indexes), multicolumn_only: true) do |index|
|
61
64
|
primary_key = connection.primary_key(table)
|
62
65
|
if index.columns == [primary_key] && index.where.nil?
|
63
|
-
problem!(extraneous_index: index.name, replacement_indexes: nil)
|
66
|
+
problem!(table: table, extraneous_index: index.name, replacement_indexes: nil)
|
64
67
|
end
|
65
68
|
end
|
66
69
|
end
|
@@ -73,13 +76,16 @@ module ActiveRecordDoctor
|
|
73
76
|
return false if index1.where != index2.where
|
74
77
|
return false if opclasses(index1) != opclasses(index2)
|
75
78
|
|
79
|
+
index1_columns = Array(index1.columns)
|
80
|
+
index2_columns = Array(index2.columns)
|
81
|
+
|
76
82
|
case [index1.unique, index2.unique]
|
77
83
|
when [true, true]
|
78
|
-
(
|
84
|
+
(index2_columns - index1_columns).empty?
|
79
85
|
when [true, false]
|
80
86
|
false
|
81
87
|
else
|
82
|
-
prefix?(
|
88
|
+
prefix?(index1_columns, index2_columns)
|
83
89
|
end
|
84
90
|
end
|
85
91
|
|
@@ -88,8 +94,7 @@ module ActiveRecordDoctor
|
|
88
94
|
end
|
89
95
|
|
90
96
|
def prefix?(lhs, rhs)
|
91
|
-
lhs.
|
92
|
-
rhs.columns[0...lhs.columns.count] == lhs.columns
|
97
|
+
lhs.count <= rhs.count && rhs[0...lhs.count] == lhs
|
93
98
|
end
|
94
99
|
end
|
95
100
|
end
|
@@ -5,7 +5,7 @@ require "active_record_doctor/detectors/base"
|
|
5
5
|
module ActiveRecordDoctor
|
6
6
|
module Detectors
|
7
7
|
class IncorrectBooleanPresenceValidation < Base # :nodoc:
|
8
|
-
@description = "detect
|
8
|
+
@description = "detect presence (instead of inclusion) validators on boolean columns"
|
9
9
|
@config = {
|
10
10
|
ignore_models: {
|
11
11
|
description: "models whose validators should not be checked",
|
@@ -18,7 +18,8 @@ module ActiveRecordDoctor
|
|
18
18
|
|
19
19
|
private
|
20
20
|
|
21
|
-
def message(model:, association:, problem:,
|
21
|
+
def message(model:, association:, problem:, associated_models_type: nil,
|
22
|
+
table_name: nil, column_name: nil, associated_models: [])
|
22
23
|
associated_models.sort!
|
23
24
|
|
24
25
|
models_part =
|
@@ -36,6 +37,9 @@ module ActiveRecordDoctor
|
|
36
37
|
case problem
|
37
38
|
when :invalid_through
|
38
39
|
"ensure #{model}.#{association} is configured correctly - #{associated_models[0]}.#{association} may be undefined"
|
40
|
+
when :destroy_async
|
41
|
+
"don't use `dependent: :destroy_async` on #{model}.#{association} or remove the foreign key from #{table_name}.#{column_name} - " \
|
42
|
+
"associated models will be deleted in the same transaction along with #{model}"
|
39
43
|
when :suggest_destroy
|
40
44
|
"use `dependent: :destroy` or similar on #{model}.#{association} - associated #{models_part} callbacks that are currently skipped"
|
41
45
|
when :suggest_delete
|
@@ -88,29 +92,45 @@ module ActiveRecordDoctor
|
|
88
92
|
|
89
93
|
deletable_models, destroyable_models = associated_models.partition { |klass| deletable?(klass) }
|
90
94
|
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
95
|
+
case association.options[:dependent]
|
96
|
+
when :destroy_async
|
97
|
+
foreign_key = foreign_key(association.klass.table_name, model.table_name)
|
98
|
+
if foreign_key
|
99
|
+
problem!(
|
100
|
+
model: model.name,
|
101
|
+
association: association.name,
|
102
|
+
table_name: foreign_key.from_table,
|
103
|
+
column_name: foreign_key.column,
|
104
|
+
problem: :destroy_async
|
105
|
+
)
|
106
|
+
end
|
107
|
+
when :destroy
|
108
|
+
if destroyable_models.empty? && deletable_models.present?
|
109
|
+
suggestion =
|
110
|
+
case association.macro
|
111
|
+
when :has_many then :suggest_delete_all
|
112
|
+
when :has_one, :belongs_to then :suggest_delete
|
113
|
+
else raise("unsupported association type #{association.macro}")
|
114
|
+
end
|
115
|
+
|
116
|
+
problem!(
|
117
|
+
model: model.name,
|
118
|
+
association: association.name,
|
119
|
+
problem: suggestion,
|
120
|
+
associated_models: deletable_models.map(&:name),
|
121
|
+
associated_models_type: associated_models_type
|
122
|
+
)
|
123
|
+
end
|
124
|
+
when :delete, :delete_all
|
125
|
+
if destroyable_models.present?
|
126
|
+
problem!(
|
127
|
+
model: model.name,
|
128
|
+
association: association.name,
|
129
|
+
problem: :suggest_destroy,
|
130
|
+
associated_models: destroyable_models.map(&:name),
|
131
|
+
associated_models_type: associated_models_type
|
132
|
+
)
|
133
|
+
end
|
114
134
|
end
|
115
135
|
end
|
116
136
|
end
|
@@ -127,13 +147,6 @@ module ActiveRecordDoctor
|
|
127
147
|
end
|
128
148
|
end
|
129
149
|
|
130
|
-
def callback_action(reflection)
|
131
|
-
case reflection.options[:dependent]
|
132
|
-
when :delete, :delete_all then :skip
|
133
|
-
when :destroy then :invoke
|
134
|
-
end
|
135
|
-
end
|
136
|
-
|
137
150
|
def deletable?(model)
|
138
151
|
!defines_destroy_callbacks?(model) &&
|
139
152
|
dependent_models(model).all? do |dependent_model|
|
@@ -29,7 +29,7 @@ module ActiveRecordDoctor
|
|
29
29
|
each_foreign_key(table) do |foreign_key|
|
30
30
|
from_column = column(table, foreign_key.column)
|
31
31
|
|
32
|
-
next if
|
32
|
+
next if ignored?("#{table}.#{from_column.name}", config(:ignore_columns))
|
33
33
|
|
34
34
|
to_table = foreign_key.to_table
|
35
35
|
to_column = column(to_table, foreign_key.primary_key)
|
@@ -26,14 +26,14 @@ module ActiveRecordDoctor
|
|
26
26
|
table_models = models.select(&:table_exists?).group_by(&:table_name)
|
27
27
|
|
28
28
|
table_models.each do |table, models|
|
29
|
-
next if config(:ignore_tables)
|
29
|
+
next if ignored?(table, config(:ignore_tables))
|
30
30
|
|
31
31
|
concrete_models = models.reject do |model|
|
32
32
|
model.abstract_class? || sti_base_model?(model)
|
33
33
|
end
|
34
34
|
|
35
35
|
connection.columns(table).each do |column|
|
36
|
-
next if
|
36
|
+
next if ignored?("#{table}.#{column.name}", config(:ignore_columns))
|
37
37
|
next if !column.null
|
38
38
|
next if !concrete_models.all? { |model| non_null_needed?(model, column) }
|
39
39
|
next if not_null_check_constraint_exists?(table, column)
|
@@ -22,9 +22,12 @@ module ActiveRecordDoctor
|
|
22
22
|
def message(model:, table:, columns:, problem:)
|
23
23
|
case problem
|
24
24
|
when :validations
|
25
|
-
"add a unique index on #{table}(#{columns.join(', ')}) - validating uniqueness in
|
25
|
+
"add a unique index on #{table}(#{columns.join(', ')}) - validating uniqueness in #{model.name} without an index can lead to duplicates"
|
26
|
+
when :case_insensitive_validations
|
27
|
+
"add a unique expression index on #{table}(#{columns.join(', ')}) - validating case-insensitive uniqueness in #{model.name} " \
|
28
|
+
"without an expression index can lead to duplicates (a regular unique index is not enough)"
|
26
29
|
when :has_ones
|
27
|
-
"add a unique index on #{table}(#{columns.
|
30
|
+
"add a unique index on #{table}(#{columns.join(', ')}) - using `has_one` in #{model.name} without an index can lead to duplicates"
|
28
31
|
end
|
29
32
|
end
|
30
33
|
# rubocop:enable Layout/LineLength
|
@@ -36,19 +39,44 @@ module ActiveRecordDoctor
|
|
36
39
|
|
37
40
|
def validations_without_indexes
|
38
41
|
each_model(except: config(:ignore_models), existing_tables_only: true) do |model|
|
39
|
-
|
42
|
+
# Skip inherited validators from STI to prevent them
|
43
|
+
# from being reported multiple times on subclasses.
|
44
|
+
validators = model.validators - model.superclass.validators
|
45
|
+
validators.each do |validator|
|
40
46
|
scope = Array(validator.options.fetch(:scope, []))
|
41
47
|
|
42
48
|
next unless validator.is_a?(ActiveRecord::Validations::UniquenessValidator)
|
43
|
-
next
|
49
|
+
next if conditional_validator?(validator)
|
50
|
+
|
51
|
+
# In Rails 6, default option values are no longer explicitly set on
|
52
|
+
# options so if the key is absent we must fetch the default value
|
53
|
+
# ourselves. case_sensitive is the default in 4.2+ so it's safe to
|
54
|
+
# put true literally.
|
55
|
+
case_sensitive = validator.options.fetch(:case_sensitive, true)
|
56
|
+
|
57
|
+
# ActiveRecord < 5.0 does not support expression indexes,
|
58
|
+
# so this will always be a false positive.
|
59
|
+
next if !case_sensitive && Utils.expression_indexes_unsupported?
|
44
60
|
|
45
61
|
validator.attributes.each do |attribute|
|
46
62
|
columns = resolve_attributes(model, scope + [attribute])
|
47
63
|
|
48
|
-
next if unique_index?(model.table_name, columns)
|
49
64
|
next if ignore_columns.include?("#{model.name}(#{columns.join(',')})")
|
50
65
|
|
51
|
-
|
66
|
+
columns[-1] = "lower(#{columns[-1]})" unless case_sensitive
|
67
|
+
|
68
|
+
next if unique_index?(model.table_name, columns)
|
69
|
+
|
70
|
+
if case_sensitive
|
71
|
+
problem!(model: model, table: model.table_name, columns: columns, problem: :validations)
|
72
|
+
else
|
73
|
+
problem!(
|
74
|
+
model: model,
|
75
|
+
table: model.table_name,
|
76
|
+
columns: columns,
|
77
|
+
problem: :case_insensitive_validations
|
78
|
+
)
|
79
|
+
end
|
52
80
|
end
|
53
81
|
end
|
54
82
|
end
|
@@ -57,29 +85,27 @@ module ActiveRecordDoctor
|
|
57
85
|
def has_ones_without_indexes # rubocop:disable Naming/PredicateName
|
58
86
|
each_model do |model|
|
59
87
|
each_association(model, type: :has_one, has_scope: false, through: false) do |has_one|
|
60
|
-
next if
|
88
|
+
next if ignored?(has_one.klass.name, config(:ignore_models))
|
61
89
|
|
62
|
-
|
63
|
-
|
90
|
+
columns =
|
91
|
+
if has_one.options[:as]
|
92
|
+
[has_one.type.to_s, has_one.foreign_key.to_s]
|
93
|
+
else
|
94
|
+
[has_one.foreign_key.to_s]
|
95
|
+
end
|
96
|
+
next if ignored?("#{has_one.klass.name}(#{columns.join(',')})", ignore_columns)
|
64
97
|
|
65
98
|
table_name = has_one.klass.table_name
|
66
|
-
next if unique_index?(table_name,
|
99
|
+
next if unique_index?(table_name, columns)
|
100
|
+
next if Array(connection.primary_key(table_name)) == columns
|
67
101
|
|
68
|
-
problem!(model: model, table: table_name, columns:
|
102
|
+
problem!(model: model, table: table_name, columns: columns, problem: :has_ones)
|
69
103
|
end
|
70
104
|
end
|
71
105
|
end
|
72
106
|
|
73
|
-
def
|
74
|
-
validator.options[:if].
|
75
|
-
validator.options[:unless].nil? &&
|
76
|
-
validator.options[:conditions].nil? &&
|
77
|
-
|
78
|
-
# In Rails 6, default option values are no longer explicitly set on
|
79
|
-
# options so if the key is absent we must fetch the default value
|
80
|
-
# ourselves. case_sensitive is the default in 4.2+ so it's safe to
|
81
|
-
# put true literally.
|
82
|
-
validator.options.fetch(:case_sensitive, true)
|
107
|
+
def conditional_validator?(validator)
|
108
|
+
(validator.options.keys & [:if, :unless, :conditions]).present?
|
83
109
|
end
|
84
110
|
|
85
111
|
def resolve_attributes(model, attributes)
|
@@ -99,17 +125,41 @@ module ActiveRecordDoctor
|
|
99
125
|
def unique_index?(table_name, columns, scope = nil)
|
100
126
|
columns = (Array(scope) + columns).map(&:to_s)
|
101
127
|
indexes(table_name).any? do |index|
|
128
|
+
index_columns =
|
129
|
+
# For expression indexes, Active Record returns columns as string.
|
130
|
+
if index.columns.is_a?(String)
|
131
|
+
extract_index_columns(index.columns)
|
132
|
+
else
|
133
|
+
index.columns
|
134
|
+
end
|
135
|
+
|
102
136
|
index.unique &&
|
103
137
|
index.where.nil? &&
|
104
|
-
(
|
138
|
+
(index_columns - columns).empty?
|
105
139
|
end
|
106
140
|
end
|
107
141
|
|
108
142
|
def ignore_columns
|
109
143
|
@ignore_columns ||= config(:ignore_columns).map do |column|
|
110
|
-
column.
|
144
|
+
if column.is_a?(String)
|
145
|
+
column.gsub(" ", "")
|
146
|
+
else
|
147
|
+
column
|
148
|
+
end
|
111
149
|
end
|
112
150
|
end
|
151
|
+
|
152
|
+
def extract_index_columns(columns)
|
153
|
+
columns
|
154
|
+
.split(",")
|
155
|
+
.map(&:strip)
|
156
|
+
.map do |column|
|
157
|
+
column.gsub(/lower\(/i, "lower(")
|
158
|
+
.gsub(/\((\w+)\)::\w+/, '\1') # (email)::string
|
159
|
+
.gsub(/([`'"])(\w+)\1/, '\2') # quoted identifiers
|
160
|
+
.gsub(/\A\((.+)\)\z/, '\1') # remove surrounding braces from MySQL
|
161
|
+
end
|
162
|
+
end
|
113
163
|
end
|
114
164
|
end
|
115
165
|
end
|
@@ -23,7 +23,7 @@ module ActiveRecordDoctor
|
|
23
23
|
each_table(except: config(:ignore_tables)) do |table|
|
24
24
|
column = primary_key(table)
|
25
25
|
next if column.nil?
|
26
|
-
next if
|
26
|
+
next if !integer?(column) || bigint?(column)
|
27
27
|
|
28
28
|
problem!(table: table, column: column.name)
|
29
29
|
end
|
@@ -37,8 +37,8 @@ module ActiveRecordDoctor
|
|
37
37
|
end
|
38
38
|
end
|
39
39
|
|
40
|
-
def
|
41
|
-
column.
|
40
|
+
def integer?(column)
|
41
|
+
column.type == :integer
|
42
42
|
end
|
43
43
|
end
|
44
44
|
end
|