active_record_doctor 1.11.0 → 1.13.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|