active_record_doctor 1.11.0 → 1.12.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 +14 -14
- data/lib/active_record_doctor/detectors/base.rb +20 -9
- data/lib/active_record_doctor/detectors/extraneous_indexes.rb +14 -9
- data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +39 -31
- data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +62 -21
- data/lib/active_record_doctor/detectors/short_primary_key_type.rb +3 -3
- data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +33 -7
- 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/test/active_record_doctor/detectors/extraneous_indexes_test.rb +59 -6
- data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +39 -0
- data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +182 -13
- data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +5 -0
- data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +39 -1
- data/test/generators/active_record_doctor/add_indexes/add_indexes_generator_test.rb +16 -6
- metadata +6 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d848e296c39f7994781bd645261eb45235450be36058fbdf7368ec6358ebeba5
|
4
|
+
data.tar.gz: a63cbbfe5b43fb3bf6daeaba24a6298073643d5b5c96696c27c084106fe54594
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 47411eea54d4c21329459dc7490090a535c53e9f3be912268ceed2f6e2588246cb07c9b90e758559d9b0c08de319f3dc6a4735c79511bc30c2ec7b058104c08e
|
7
|
+
data.tar.gz: 9596cb799a4346b3ec60562876750990b486efc7daebfda0834b028bf2db9075f04bf6b028185df6076ede3a7227cb9755a6ac9e8db609431d063261f3c0732d
|
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)
|
@@ -158,7 +158,7 @@ three-step process:
|
|
158
158
|
```
|
159
159
|
|
160
160
|
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.
|
161
|
+
as a column can look like a foreign key (i.e. ending with `_id`) without being
|
162
162
|
one.
|
163
163
|
|
164
164
|
3. Generate the migrations
|
@@ -209,7 +209,7 @@ To discover such indexes automatically just follow these steps:
|
|
209
209
|
|
210
210
|
3. Create a migration to drop the indexes.
|
211
211
|
|
212
|
-
The indexes aren't dropped automatically because there
|
212
|
+
The indexes aren't dropped automatically because there are usually just a few of
|
213
213
|
them and it's a good idea to double-check that you won't drop something
|
214
214
|
necessary.
|
215
215
|
|
@@ -265,11 +265,11 @@ Supported configuration options:
|
|
265
265
|
If `users.profile_id` references a row in `profiles` then this can be expressed
|
266
266
|
at the database level with a foreign key constraint. It _forces_
|
267
267
|
`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
|
268
|
+
that in many legacy Rails apps, the constraint isn't enforced at the database
|
269
269
|
level.
|
270
270
|
|
271
271
|
`active_record_doctor` can automatically detect foreign keys that could benefit
|
272
|
-
from a foreign key constraint (a future version will generate a
|
272
|
+
from a foreign key constraint (a future version will generate a migration that
|
273
273
|
add the constraint; for now, it's your job). You can obtain the list of foreign
|
274
274
|
keys with the following command:
|
275
275
|
|
@@ -307,7 +307,7 @@ before they hit production.
|
|
307
307
|
* Rails 5+ and _any_ database or
|
308
308
|
* Rails 4.2 with PostgreSQL.
|
309
309
|
|
310
|
-
The only
|
310
|
+
The only thing you need to do is run:
|
311
311
|
|
312
312
|
```
|
313
313
|
bundle exec rake active_record_doctor:undefined_table_references
|
@@ -320,7 +320,7 @@ this:
|
|
320
320
|
Contract references a non-existent table or view named contract_records
|
321
321
|
```
|
322
322
|
|
323
|
-
On top of that `rake` will exit with status code of 1. This allows you to use
|
323
|
+
On top of that `rake` will exit with a status code of 1. This allows you to use
|
324
324
|
this check as part of your Continuous Integration pipeline.
|
325
325
|
|
326
326
|
Supported configuration options:
|
@@ -333,7 +333,7 @@ Supported configuration options:
|
|
333
333
|
|
334
334
|
Model-level uniqueness validations and `has_one` associations should be backed
|
335
335
|
by a database index in order to be robust. Otherwise you risk inserting
|
336
|
-
duplicate values under heavy load.
|
336
|
+
duplicate values under a heavy load.
|
337
337
|
|
338
338
|
In order to detect such validations run:
|
339
339
|
|
@@ -475,7 +475,7 @@ Supported configuration options:
|
|
475
475
|
|
476
476
|
- `enabled` - set to `false` to disable the detector altogether
|
477
477
|
- `ignore_models` - models whose validators should not be checked.
|
478
|
-
- `
|
478
|
+
- `ignore_attributes` - attributes, written as Model.attribute, whose validators
|
479
479
|
should not be checked.
|
480
480
|
|
481
481
|
### Detecting Incorrect `dependent` Option on Associations
|
@@ -489,7 +489,7 @@ This can lead to two types of errors:
|
|
489
489
|
- Using `delete_all` when dependent models define callbacks - they will NOT be
|
490
490
|
invoked.
|
491
491
|
- Using `destroy` when dependent models define no callbacks - dependent models
|
492
|
-
will be loaded one
|
492
|
+
will be loaded one by one with no reason
|
493
493
|
|
494
494
|
In order to detect associations affected by the two aforementioned problems run
|
495
495
|
the following command:
|
@@ -530,8 +530,8 @@ The output of the command looks like this:
|
|
530
530
|
change the type of companies.id to bigint
|
531
531
|
```
|
532
532
|
|
533
|
-
The above means `
|
534
|
-
example migration to accomplish this looks
|
533
|
+
The above means `companies.id` should be migrated to a wider integer type. An
|
534
|
+
example migration to accomplish this looks like this:
|
535
535
|
|
536
536
|
```ruby
|
537
537
|
class ChangeCompaniesPrimaryKeyType < ActiveRecord::Migration[5.1]
|
@@ -565,7 +565,7 @@ bundle exec rake active_record_doctor:mismatched_foreign_key_type
|
|
565
565
|
The output of the command looks like this:
|
566
566
|
|
567
567
|
```
|
568
|
-
companies.user_id references a column of different type - foreign keys should be of the same type as the referenced column
|
568
|
+
companies.user_id references a column of a different type - foreign keys should be of the same type as the referenced column
|
569
569
|
```
|
570
570
|
|
571
571
|
Supported configuration options:
|
@@ -584,7 +584,7 @@ combinations of Ruby and Rails versions. Specifically:
|
|
584
584
|
supported by `active_record_doctor`.
|
585
585
|
2. If a Ruby version is compatible with a supported Rails version then it's
|
586
586
|
also supported by `active_record_doctor`.
|
587
|
-
3. Only most recent teeny Ruby versions and patch Rails versions are supported.
|
587
|
+
3. Only the most recent teeny Ruby versions and patch Rails versions are supported.
|
588
588
|
|
589
589
|
## Author
|
590
590
|
|
@@ -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,10 +164,6 @@ 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|
|
@@ -281,14 +277,29 @@ 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 except.include?(data_source)
|
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
|
@@ -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
|
@@ -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,40 @@ 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!(model: model.name, association: association.name,
|
100
|
+
table_name: foreign_key.from_table, column_name: foreign_key.column, problem: :destroy_async)
|
101
|
+
end
|
102
|
+
when :destroy
|
103
|
+
if destroyable_models.empty? && deletable_models.present?
|
104
|
+
suggestion =
|
105
|
+
case association.macro
|
106
|
+
when :has_many then :suggest_delete_all
|
107
|
+
when :has_one, :belongs_to then :suggest_delete
|
108
|
+
else raise("unsupported association type #{association.macro}")
|
109
|
+
end
|
110
|
+
|
111
|
+
problem!(
|
112
|
+
model: model.name,
|
113
|
+
association: association.name,
|
114
|
+
problem: suggestion,
|
115
|
+
associated_models: deletable_models.map(&:name),
|
116
|
+
associated_models_type: associated_models_type
|
117
|
+
)
|
118
|
+
end
|
119
|
+
when :delete, :delete_all
|
120
|
+
if destroyable_models.present?
|
121
|
+
problem!(
|
122
|
+
model: model.name,
|
123
|
+
association: association.name,
|
124
|
+
problem: :suggest_destroy,
|
125
|
+
associated_models: destroyable_models.map(&:name),
|
126
|
+
associated_models_type: associated_models_type
|
127
|
+
)
|
128
|
+
end
|
114
129
|
end
|
115
130
|
end
|
116
131
|
end
|
@@ -127,13 +142,6 @@ module ActiveRecordDoctor
|
|
127
142
|
end
|
128
143
|
end
|
129
144
|
|
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
145
|
def deletable?(model)
|
138
146
|
!defines_destroy_callbacks?(model) &&
|
139
147
|
dependent_models(model).all? do |dependent_model|
|
@@ -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,40 @@ 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!(model: model, table: model.table_name, columns: columns,
|
74
|
+
problem: :case_insensitive_validations)
|
75
|
+
end
|
52
76
|
end
|
53
77
|
end
|
54
78
|
end
|
@@ -59,27 +83,24 @@ module ActiveRecordDoctor
|
|
59
83
|
each_association(model, type: :has_one, has_scope: false, through: false) do |has_one|
|
60
84
|
next if config(:ignore_models).include?(has_one.klass.name)
|
61
85
|
|
62
|
-
|
63
|
-
|
86
|
+
columns =
|
87
|
+
if has_one.options[:as]
|
88
|
+
[has_one.type.to_s, has_one.foreign_key.to_s]
|
89
|
+
else
|
90
|
+
[has_one.foreign_key.to_s]
|
91
|
+
end
|
92
|
+
next if ignore_columns.include?("#{model.name}(#{columns.join(',')})")
|
64
93
|
|
65
94
|
table_name = has_one.klass.table_name
|
66
|
-
next if unique_index?(table_name,
|
95
|
+
next if unique_index?(table_name, columns)
|
67
96
|
|
68
|
-
problem!(model: model, table: table_name, columns:
|
97
|
+
problem!(model: model, table: table_name, columns: columns, problem: :has_ones)
|
69
98
|
end
|
70
99
|
end
|
71
100
|
end
|
72
101
|
|
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)
|
102
|
+
def conditional_validator?(validator)
|
103
|
+
(validator.options.keys & [:if, :unless, :conditions]).present?
|
83
104
|
end
|
84
105
|
|
85
106
|
def resolve_attributes(model, attributes)
|
@@ -99,9 +120,17 @@ module ActiveRecordDoctor
|
|
99
120
|
def unique_index?(table_name, columns, scope = nil)
|
100
121
|
columns = (Array(scope) + columns).map(&:to_s)
|
101
122
|
indexes(table_name).any? do |index|
|
123
|
+
index_columns =
|
124
|
+
# For expression indexes, Active Record returns columns as string.
|
125
|
+
if index.columns.is_a?(String)
|
126
|
+
extract_index_columns(index.columns)
|
127
|
+
else
|
128
|
+
index.columns
|
129
|
+
end
|
130
|
+
|
102
131
|
index.unique &&
|
103
132
|
index.where.nil? &&
|
104
|
-
(
|
133
|
+
(index_columns - columns).empty?
|
105
134
|
end
|
106
135
|
end
|
107
136
|
|
@@ -110,6 +139,18 @@ module ActiveRecordDoctor
|
|
110
139
|
column.gsub(" ", "")
|
111
140
|
end
|
112
141
|
end
|
142
|
+
|
143
|
+
def extract_index_columns(columns)
|
144
|
+
columns
|
145
|
+
.split(",")
|
146
|
+
.map(&:strip)
|
147
|
+
.map do |column|
|
148
|
+
column.gsub(/lower\(/i, "lower(")
|
149
|
+
.gsub(/\((\w+)\)::\w+/, '\1') # (email)::string
|
150
|
+
.gsub(/([`'"])(\w+)\1/, '\2') # quoted identifiers
|
151
|
+
.gsub(/\A\((.+)\)\z/, '\1') # remove surrounding braces from MySQL
|
152
|
+
end
|
153
|
+
end
|
113
154
|
end
|
114
155
|
end
|
115
156
|
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
|
@@ -18,28 +18,43 @@ module ActiveRecordDoctor
|
|
18
18
|
|
19
19
|
private
|
20
20
|
|
21
|
-
def message(table:,
|
21
|
+
def message(table:, columns:)
|
22
22
|
# rubocop:disable Layout/LineLength
|
23
|
-
"add an index on #{table}
|
23
|
+
"add an index on #{table}(#{columns.join(', ')}) - foreign keys are often used in database lookups and should be indexed for performance reasons"
|
24
24
|
# rubocop:enable Layout/LineLength
|
25
25
|
end
|
26
26
|
|
27
27
|
def detect
|
28
28
|
each_table(except: config(:ignore_tables)) do |table|
|
29
29
|
each_column(table, except: config(:ignore_columns)) do |column|
|
30
|
-
next unless foreign_key?(column)
|
30
|
+
next unless named_like_foreign_key?(column) || foreign_key?(table, column)
|
31
31
|
next if indexed?(table, column)
|
32
32
|
next if indexed_as_polymorphic?(table, column)
|
33
33
|
|
34
|
-
|
34
|
+
type_column_name = type_column_name(column)
|
35
|
+
|
36
|
+
columns =
|
37
|
+
if column_exists?(table, type_column_name)
|
38
|
+
[type_column_name, column.name]
|
39
|
+
else
|
40
|
+
[column.name]
|
41
|
+
end
|
42
|
+
|
43
|
+
problem!(table: table, columns: columns)
|
35
44
|
end
|
36
45
|
end
|
37
46
|
end
|
38
47
|
|
39
|
-
def
|
48
|
+
def named_like_foreign_key?(column)
|
40
49
|
column.name.end_with?("_id")
|
41
50
|
end
|
42
51
|
|
52
|
+
def foreign_key?(table, column)
|
53
|
+
connection.foreign_keys(table).any? do |foreign_key|
|
54
|
+
foreign_key.column == column.name
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
43
58
|
def indexed?(table, column)
|
44
59
|
connection.indexes(table).any? do |index|
|
45
60
|
index.columns.first == column.name
|
@@ -47,9 +62,20 @@ module ActiveRecordDoctor
|
|
47
62
|
end
|
48
63
|
|
49
64
|
def indexed_as_polymorphic?(table, column)
|
50
|
-
type_column_name = column.name.sub(/_id\Z/, "_type")
|
51
65
|
connection.indexes(table).any? do |index|
|
52
|
-
index.columns == [type_column_name, column.name]
|
66
|
+
index.columns[0, 2] == [type_column_name(column), column.name]
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def column_exists?(table, column_name)
|
71
|
+
connection.columns(table).any? { |column| column.name == column_name }
|
72
|
+
end
|
73
|
+
|
74
|
+
def type_column_name(column)
|
75
|
+
if column.name.end_with?("_id")
|
76
|
+
column.name.sub(/_id\Z/, "_type")
|
77
|
+
else
|
78
|
+
"#{column.name}_type"
|
53
79
|
end
|
54
80
|
end
|
55
81
|
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecordDoctor
|
4
|
+
module Utils # :nodoc:
|
5
|
+
class << self
|
6
|
+
def postgresql?(connection = ActiveRecord::Base.connection)
|
7
|
+
["PostgreSQL", "PostGIS"].include?(connection.adapter_name)
|
8
|
+
end
|
9
|
+
|
10
|
+
def mysql?(connection = ActiveRecord::Base.connection)
|
11
|
+
connection.adapter_name == "Mysql2"
|
12
|
+
end
|
13
|
+
|
14
|
+
def expression_indexes_unsupported?(connection = ActiveRecord::Base.connection)
|
15
|
+
(ActiveRecord::VERSION::STRING < "5.0") ||
|
16
|
+
# Active Record < 6 is unable to correctly parse expression indexes for MySQL.
|
17
|
+
(mysql?(connection) && ActiveRecord::VERSION::STRING < "6.0")
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
data/lib/active_record_doctor.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "active_record_doctor/railtie" if defined?(Rails) && defined?(Rails::Railtie)
|
4
|
+
require "active_record_doctor/utils"
|
4
5
|
require "active_record_doctor/logger"
|
5
6
|
require "active_record_doctor/logger/dummy"
|
6
7
|
require "active_record_doctor/logger/hierarchical"
|
@@ -25,6 +26,7 @@ require "active_record_doctor/runner"
|
|
25
26
|
require "active_record_doctor/version"
|
26
27
|
require "active_record_doctor/config"
|
27
28
|
require "active_record_doctor/config/loader"
|
29
|
+
require "active_record_doctor/rake/task"
|
28
30
|
|
29
31
|
module ActiveRecordDoctor # :nodoc:
|
30
32
|
end
|
@@ -10,16 +10,16 @@ module ActiveRecordDoctor
|
|
10
10
|
migration_descriptions = read_migration_descriptions(path)
|
11
11
|
now = Time.now
|
12
12
|
|
13
|
-
migration_descriptions.each_with_index do |(table,
|
13
|
+
migration_descriptions.each_with_index do |(table, indexes), index|
|
14
14
|
timestamp = (now + index).strftime("%Y%m%d%H%M%S")
|
15
15
|
file_name = "db/migrate/#{timestamp}_index_foreign_keys_in_#{table}.rb"
|
16
|
-
create_file(file_name, content(table,
|
16
|
+
create_file(file_name, content(table, indexes).tap { |x| puts x })
|
17
17
|
end
|
18
18
|
end
|
19
19
|
|
20
20
|
private
|
21
21
|
|
22
|
-
INPUT_LINE = /^add an index on (\w+)
|
22
|
+
INPUT_LINE = /^add an index on (\w+)\((.+)\) - .*$/.freeze
|
23
23
|
private_constant :INPUT_LINE
|
24
24
|
|
25
25
|
def read_migration_descriptions(path)
|
@@ -34,41 +34,41 @@ module ActiveRecordDoctor
|
|
34
34
|
end
|
35
35
|
|
36
36
|
table = match[1]
|
37
|
-
|
37
|
+
columns = match[2].split(",").map(&:strip)
|
38
38
|
|
39
|
-
tables_to_columns[table] <<
|
39
|
+
tables_to_columns[table] << columns
|
40
40
|
end
|
41
41
|
|
42
42
|
tables_to_columns
|
43
43
|
end
|
44
44
|
|
45
|
-
def content(table,
|
45
|
+
def content(table, indexes)
|
46
46
|
# In order to properly indent the resulting code, we must disable the
|
47
47
|
# rubocop rule below.
|
48
48
|
|
49
49
|
<<MIGRATION
|
50
50
|
class IndexForeignKeysIn#{table.camelize} < ActiveRecord::Migration#{migration_version}
|
51
51
|
def change
|
52
|
-
#{add_indexes(table,
|
52
|
+
#{add_indexes(table, indexes)}
|
53
53
|
end
|
54
54
|
end
|
55
55
|
MIGRATION
|
56
56
|
end
|
57
57
|
|
58
|
-
def add_indexes(table,
|
59
|
-
|
60
|
-
add_index(table,
|
58
|
+
def add_indexes(table, indexes)
|
59
|
+
indexes.map do |columns|
|
60
|
+
add_index(table, columns)
|
61
61
|
end.join("\n")
|
62
62
|
end
|
63
63
|
|
64
|
-
def add_index(table,
|
64
|
+
def add_index(table, columns)
|
65
65
|
connection = ActiveRecord::Base.connection
|
66
66
|
|
67
|
-
index_name = connection.index_name(table,
|
67
|
+
index_name = connection.index_name(table, columns)
|
68
68
|
if index_name.size > connection.index_name_length
|
69
|
-
" add_index :#{table},
|
69
|
+
" add_index :#{table}, #{columns.inspect}, name: '#{index_name.first(connection.index_name_length)}'"
|
70
70
|
else
|
71
|
-
" add_index :#{table},
|
71
|
+
" add_index :#{table}, #{columns.inspect}"
|
72
72
|
end
|
73
73
|
end
|
74
74
|
|
@@ -7,7 +7,7 @@ class ActiveRecordDoctor::Detectors::ExtraneousIndexesTest < Minitest::Test
|
|
7
7
|
end
|
8
8
|
|
9
9
|
assert_problems(<<OUTPUT)
|
10
|
-
remove index_users_on_id - coincides with the primary key on the table
|
10
|
+
remove index_users_on_id from users - coincides with the primary key on the table
|
11
11
|
OUTPUT
|
12
12
|
end
|
13
13
|
|
@@ -28,7 +28,7 @@ OUTPUT
|
|
28
28
|
end
|
29
29
|
|
30
30
|
assert_problems(<<OUTPUT)
|
31
|
-
remove index_profiles_on_user_id - coincides with the primary key on the table
|
31
|
+
remove index_profiles_on_user_id from profiles - coincides with the primary key on the table
|
32
32
|
OUTPUT
|
33
33
|
end
|
34
34
|
|
@@ -42,7 +42,7 @@ OUTPUT
|
|
42
42
|
ActiveRecord::Base.connection.add_index :users, :email, name: "index_users_on_email"
|
43
43
|
|
44
44
|
assert_problems(<<OUTPUT)
|
45
|
-
remove index_users_on_email -
|
45
|
+
remove the index index_users_on_email from the table users - queries should be able to use the following index instead: unique_index_on_users_email
|
46
46
|
OUTPUT
|
47
47
|
end
|
48
48
|
|
@@ -59,7 +59,7 @@ OUTPUT
|
|
59
59
|
end
|
60
60
|
|
61
61
|
assert_problems(<<OUTPUT)
|
62
|
-
remove index_users_on_last_name -
|
62
|
+
remove the index index_users_on_last_name from the table users - queries should be able to use the following indices instead: index_users_on_last_name_and_first_name_and_email or unique_index_on_users_last_name_and_first_name
|
63
63
|
OUTPUT
|
64
64
|
end
|
65
65
|
|
@@ -78,7 +78,7 @@ OUTPUT
|
|
78
78
|
ActiveRecord::Base.connection.add_index :users, [:last_name, :first_name]
|
79
79
|
|
80
80
|
assert_problems(<<OUTPUT)
|
81
|
-
remove index_users_on_last_name_and_first_name -
|
81
|
+
remove the index index_users_on_last_name_and_first_name from the table users - queries should be able to use the following indices instead: index_users_on_last_name_and_first_name_and_email or unique_index_on_users_last_name_and_first_name
|
82
82
|
OUTPUT
|
83
83
|
end
|
84
84
|
|
@@ -91,10 +91,36 @@ OUTPUT
|
|
91
91
|
end
|
92
92
|
|
93
93
|
assert_problems(<<OUTPUT)
|
94
|
-
remove index_users_on_last_name_and_first_name -
|
94
|
+
remove the index index_users_on_last_name_and_first_name from the table users - queries should be able to use the following index instead: index_users_on_first_name
|
95
95
|
OUTPUT
|
96
96
|
end
|
97
97
|
|
98
|
+
def test_expression_index_not_covered_by_multicolumn_index
|
99
|
+
skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
|
100
|
+
|
101
|
+
create_table(:users) do |t|
|
102
|
+
t.string :first_name
|
103
|
+
t.string :email
|
104
|
+
t.index "(lower(email))"
|
105
|
+
t.index [:first_name, :email]
|
106
|
+
end
|
107
|
+
|
108
|
+
refute_problems
|
109
|
+
end
|
110
|
+
|
111
|
+
def test_unique_expression_index_not_covered_by_unique_multicolumn_index
|
112
|
+
skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
|
113
|
+
|
114
|
+
create_table(:users) do |t|
|
115
|
+
t.string :first_name
|
116
|
+
t.string :email
|
117
|
+
t.index "(lower(email))", unique: true
|
118
|
+
t.index [:first_name, :email], unique: true
|
119
|
+
end
|
120
|
+
|
121
|
+
refute_problems
|
122
|
+
end
|
123
|
+
|
98
124
|
def test_not_covered_by_different_index_type
|
99
125
|
create_table(:users) do |t|
|
100
126
|
t.string :first_name
|
@@ -139,6 +165,33 @@ OUTPUT
|
|
139
165
|
refute_problems
|
140
166
|
end
|
141
167
|
|
168
|
+
def test_single_column_covered_by_multi_column_on_materialized_view_is_duplicate
|
169
|
+
skip("Only PostgreSQL supports materialized views") unless postgresql?
|
170
|
+
|
171
|
+
begin
|
172
|
+
create_table(:users) do |t|
|
173
|
+
t.string :first_name
|
174
|
+
t.string :last_name
|
175
|
+
t.integer :age
|
176
|
+
end
|
177
|
+
|
178
|
+
connection = ActiveRecord::Base.connection
|
179
|
+
connection.execute(<<-SQL)
|
180
|
+
CREATE MATERIALIZED VIEW user_initials AS
|
181
|
+
SELECT first_name, last_name FROM users
|
182
|
+
SQL
|
183
|
+
|
184
|
+
connection.add_index(:user_initials, [:last_name, :first_name])
|
185
|
+
connection.add_index(:user_initials, :last_name)
|
186
|
+
|
187
|
+
assert_problems(<<OUTPUT)
|
188
|
+
remove the index index_user_initials_on_last_name from the table user_initials - queries should be able to use the following index instead: index_user_initials_on_last_name_and_first_name
|
189
|
+
OUTPUT
|
190
|
+
ensure
|
191
|
+
connection.execute("DROP MATERIALIZED VIEW user_initials")
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
142
195
|
def test_config_ignore_tables
|
143
196
|
# The detector recognizes two kinds of errors and both must take
|
144
197
|
# ignore_tables into account. We trigger those errors by indexing the
|
@@ -405,6 +405,45 @@ class ActiveRecordDoctor::Detectors::IncorrectDependentOptionTest < Minitest::Te
|
|
405
405
|
OUTPUT
|
406
406
|
end
|
407
407
|
|
408
|
+
def test_destroy_async_and_foreign_key_exists
|
409
|
+
skip("ActiveRecord < 6.1 doesn't support :destroy_async") if ActiveRecord::VERSION::STRING < "6.1"
|
410
|
+
|
411
|
+
create_table(:companies) do
|
412
|
+
end.define_model do
|
413
|
+
# We need an ActiveJob job defined to appease the ActiveRecord
|
414
|
+
class_attribute :destroy_association_async_job, default: Class.new
|
415
|
+
|
416
|
+
has_many :users, dependent: :destroy_async
|
417
|
+
end
|
418
|
+
|
419
|
+
create_table(:users) do |t|
|
420
|
+
t.references :company, foreign_key: true
|
421
|
+
end.define_model
|
422
|
+
|
423
|
+
assert_problems(<<~OUTPUT)
|
424
|
+
don't use `dependent: :destroy_async` on TransientRecord::Models::Company.users or remove the foreign key from users.company_id - \
|
425
|
+
associated models will be deleted in the same transaction along with TransientRecord::Models::Company
|
426
|
+
OUTPUT
|
427
|
+
end
|
428
|
+
|
429
|
+
def test_destroy_async_and_no_foreign_key
|
430
|
+
skip("ActiveRecord < 6.1 doesn't support :destroy_async") if ActiveRecord::VERSION::STRING < "6.1"
|
431
|
+
|
432
|
+
create_table(:companies) do
|
433
|
+
end.define_model do
|
434
|
+
# We need an ActiveJob job defined to appease the ActiveRecord
|
435
|
+
class_attribute :destroy_association_async_job, default: Class.new
|
436
|
+
|
437
|
+
has_many :users, dependent: :destroy_async
|
438
|
+
end
|
439
|
+
|
440
|
+
create_table(:users) do |t|
|
441
|
+
t.references :company, foreign_key: false
|
442
|
+
end.define_model
|
443
|
+
|
444
|
+
refute_problems
|
445
|
+
end
|
446
|
+
|
408
447
|
def test_config_ignore_models
|
409
448
|
create_table(:companies) do
|
410
449
|
end.define_model do
|
@@ -10,7 +10,7 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
|
|
10
10
|
end
|
11
11
|
|
12
12
|
assert_problems(<<~OUTPUT)
|
13
|
-
add a unique index on users(email) - validating uniqueness in
|
13
|
+
add a unique index on users(email) - validating uniqueness in TransientRecord::Models::User without an index can lead to duplicates
|
14
14
|
OUTPUT
|
15
15
|
end
|
16
16
|
|
@@ -39,8 +39,8 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
|
|
39
39
|
end
|
40
40
|
|
41
41
|
assert_problems(<<~OUTPUT)
|
42
|
-
add a unique index on users(email) - validating uniqueness in
|
43
|
-
add a unique index on users(ref_token) - validating uniqueness in
|
42
|
+
add a unique index on users(email) - validating uniqueness in TransientRecord::Models::User without an index can lead to duplicates
|
43
|
+
add a unique index on users(ref_token) - validating uniqueness in TransientRecord::Models::User without an index can lead to duplicates
|
44
44
|
OUTPUT
|
45
45
|
end
|
46
46
|
|
@@ -55,6 +55,25 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
|
|
55
55
|
refute_problems
|
56
56
|
end
|
57
57
|
|
58
|
+
def test_missing_unique_index_reported_only_on_base_class
|
59
|
+
create_table(:users) do |t|
|
60
|
+
t.string :type
|
61
|
+
t.string :email
|
62
|
+
t.string :name
|
63
|
+
end.define_model do
|
64
|
+
validates :email, uniqueness: true
|
65
|
+
end
|
66
|
+
|
67
|
+
define_model(:Client, TransientRecord::Models::User) do
|
68
|
+
validates :name, uniqueness: true
|
69
|
+
end
|
70
|
+
|
71
|
+
assert_problems(<<~OUTPUT)
|
72
|
+
add a unique index on users(email) - validating uniqueness in TransientRecord::Models::User without an index can lead to duplicates
|
73
|
+
add a unique index on users(name) - validating uniqueness in TransientRecord::Models::Client without an index can lead to duplicates
|
74
|
+
OUTPUT
|
75
|
+
end
|
76
|
+
|
58
77
|
def test_present_partial_unique_index
|
59
78
|
skip("MySQL doesn't support partial indexes") if mysql?
|
60
79
|
|
@@ -67,7 +86,7 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
|
|
67
86
|
end
|
68
87
|
|
69
88
|
assert_problems(<<~OUTPUT)
|
70
|
-
add a unique index on users(email) - validating uniqueness in
|
89
|
+
add a unique index on users(email) - validating uniqueness in TransientRecord::Models::User without an index can lead to duplicates
|
71
90
|
OUTPUT
|
72
91
|
end
|
73
92
|
|
@@ -82,7 +101,7 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
|
|
82
101
|
end
|
83
102
|
|
84
103
|
assert_problems(<<~OUTPUT)
|
85
|
-
add a unique index on users(company_id, department_id, email) - validating uniqueness in
|
104
|
+
add a unique index on users(company_id, department_id, email) - validating uniqueness in TransientRecord::Models::User without an index can lead to duplicates
|
86
105
|
OUTPUT
|
87
106
|
end
|
88
107
|
|
@@ -121,7 +140,7 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
|
|
121
140
|
end
|
122
141
|
|
123
142
|
assert_problems(<<~OUTPUT)
|
124
|
-
add a unique index on users(account_id) - validating uniqueness in
|
143
|
+
add a unique index on users(account_id) - validating uniqueness in TransientRecord::Models::User without an index can lead to duplicates
|
125
144
|
OUTPUT
|
126
145
|
end
|
127
146
|
|
@@ -148,7 +167,7 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
|
|
148
167
|
end
|
149
168
|
|
150
169
|
assert_problems(<<~OUTPUT)
|
151
|
-
add a unique index on comments(commentable_type, commentable_id, title) - validating uniqueness in
|
170
|
+
add a unique index on comments(commentable_type, commentable_id, title) - validating uniqueness in TransientRecord::Models::Comment without an index can lead to duplicates
|
152
171
|
OUTPUT
|
153
172
|
end
|
154
173
|
|
@@ -179,12 +198,112 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
|
|
179
198
|
refute_problems
|
180
199
|
end
|
181
200
|
|
182
|
-
def
|
183
|
-
|
201
|
+
def test_case_insensitive_unique_index_exists
|
202
|
+
skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
|
203
|
+
|
204
|
+
create_table(:users) do |t|
|
205
|
+
t.string :email
|
206
|
+
|
207
|
+
t.index :email, unique: true
|
208
|
+
end.define_model do
|
209
|
+
validates :email, uniqueness: { case_sensitive: false }
|
210
|
+
end
|
211
|
+
|
212
|
+
assert_problems(<<~OUTPUT)
|
213
|
+
add a unique expression index on users(lower(email)) - validating case-insensitive uniqueness in TransientRecord::Models::User without an expression index can lead to duplicates (a regular unique index is not enough)
|
214
|
+
OUTPUT
|
215
|
+
end
|
216
|
+
|
217
|
+
def test_case_insensitive_non_unique_lower_index_exists
|
218
|
+
skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
|
219
|
+
|
220
|
+
create_table(:users) do |t|
|
221
|
+
t.string :email
|
222
|
+
end.define_model do
|
223
|
+
validates :email, uniqueness: { case_sensitive: false }
|
224
|
+
end
|
225
|
+
|
226
|
+
# ActiveRecord < 5 does not support expression indexes.
|
227
|
+
ActiveRecord::Base.connection.execute(<<-SQL)
|
228
|
+
CREATE INDEX index_users_on_lower_email ON users ((lower(email)))
|
229
|
+
SQL
|
230
|
+
|
231
|
+
assert_problems(<<~OUTPUT)
|
232
|
+
add a unique expression index on users(lower(email)) - validating case-insensitive uniqueness in TransientRecord::Models::User without an expression index can lead to duplicates (a regular unique index is not enough)
|
233
|
+
OUTPUT
|
234
|
+
end
|
235
|
+
|
236
|
+
def test_case_insensitive_unique_lower_index_exists
|
237
|
+
skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
|
238
|
+
|
239
|
+
create_table(:users) do |t|
|
240
|
+
t.string :email
|
241
|
+
end.define_model do
|
242
|
+
validates :email, uniqueness: { case_sensitive: false }
|
243
|
+
end
|
244
|
+
|
245
|
+
# ActiveRecord < 5 does not support expression indexes.
|
246
|
+
ActiveRecord::Base.connection.execute(<<-SQL)
|
247
|
+
CREATE UNIQUE INDEX index_users_on_lower_email ON users ((lower(email)))
|
248
|
+
SQL
|
249
|
+
|
250
|
+
refute_problems
|
251
|
+
end
|
252
|
+
|
253
|
+
def test_case_insensitive_compound_unique_index_exists
|
254
|
+
skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
|
255
|
+
|
256
|
+
create_table(:users) do |t|
|
257
|
+
t.string :email
|
258
|
+
t.integer :organization_id
|
259
|
+
t.index [:email, :organization_id], unique: true
|
260
|
+
end.define_model do
|
261
|
+
validates :email, uniqueness: { scope: :organization_id, case_sensitive: false }
|
262
|
+
end
|
263
|
+
|
264
|
+
assert_problems(<<~OUTPUT)
|
265
|
+
add a unique expression index on users(organization_id, lower(email)) - validating case-insensitive uniqueness in TransientRecord::Models::User without an expression index can lead to duplicates (a regular unique index is not enough)
|
266
|
+
OUTPUT
|
267
|
+
end
|
268
|
+
|
269
|
+
def test_case_insensitive_compound_non_unique_lower_index_exists
|
270
|
+
skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
|
271
|
+
|
272
|
+
create_table(:users) do |t|
|
273
|
+
t.string :email
|
274
|
+
t.integer :organization_id
|
275
|
+
end.define_model do
|
276
|
+
validates :email, uniqueness: { scope: :organization_id, case_sensitive: false }
|
277
|
+
end
|
278
|
+
|
279
|
+
ActiveRecord::Base.connection.execute(<<-SQL)
|
280
|
+
CREATE INDEX index_users_on_lower_email_and_organization_id ON users ((lower(email)), organization_id)
|
281
|
+
SQL
|
282
|
+
|
283
|
+
assert_problems(<<~OUTPUT)
|
284
|
+
add a unique expression index on users(organization_id, lower(email)) - validating case-insensitive uniqueness in TransientRecord::Models::User without an expression index can lead to duplicates (a regular unique index is not enough)
|
285
|
+
OUTPUT
|
286
|
+
end
|
287
|
+
|
288
|
+
def test_case_insensitive_compound_unique_lower_index_exists
|
289
|
+
skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
|
290
|
+
|
291
|
+
create_table(:users) do |t|
|
292
|
+
t.string :email
|
293
|
+
t.integer :organization_id
|
294
|
+
end.define_model do
|
295
|
+
validates :email, uniqueness: { scope: :organization_id, case_sensitive: false }
|
296
|
+
end
|
297
|
+
|
298
|
+
ActiveRecord::Base.connection.execute(<<-SQL)
|
299
|
+
CREATE UNIQUE INDEX index_users_on_lower_email_and_organization_id ON users ((lower(email)), organization_id)
|
300
|
+
SQL
|
301
|
+
|
302
|
+
refute_problems
|
184
303
|
end
|
185
304
|
|
186
|
-
def
|
187
|
-
assert_skipped(
|
305
|
+
def test_conditions_is_skipped
|
306
|
+
assert_skipped(conditions: -> { where.not(email: nil) })
|
188
307
|
end
|
189
308
|
|
190
309
|
def test_if_is_skipped
|
@@ -226,8 +345,8 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
|
|
226
345
|
end
|
227
346
|
|
228
347
|
assert_problems(<<~OUTPUT)
|
229
|
-
add a unique index on accounts(user_id) - using `has_one` in
|
230
|
-
add a unique index on account_histories(account_id) - using `has_one` in
|
348
|
+
add a unique index on accounts(user_id) - using `has_one` in TransientRecord::Models::User without an index can lead to duplicates
|
349
|
+
add a unique index on account_histories(account_id) - using `has_one` in TransientRecord::Models::Account without an index can lead to duplicates
|
231
350
|
OUTPUT
|
232
351
|
end
|
233
352
|
|
@@ -244,6 +363,24 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
|
|
244
363
|
refute_problems
|
245
364
|
end
|
246
365
|
|
366
|
+
def test_missing_has_one_unique_index_reported_only_on_base_class
|
367
|
+
create_table(:users) do |t|
|
368
|
+
t.string :type
|
369
|
+
end.define_model do
|
370
|
+
has_one :account, class_name: "TransientRecord::Models::Account"
|
371
|
+
end
|
372
|
+
|
373
|
+
define_model(:Client, TransientRecord::Models::User)
|
374
|
+
|
375
|
+
create_table(:accounts) do |t|
|
376
|
+
t.integer :user_id
|
377
|
+
end.define_model
|
378
|
+
|
379
|
+
assert_problems(<<~OUTPUT)
|
380
|
+
add a unique index on accounts(user_id) - using `has_one` in TransientRecord::Models::User without an index can lead to duplicates
|
381
|
+
OUTPUT
|
382
|
+
end
|
383
|
+
|
247
384
|
def test_has_one_with_index
|
248
385
|
create_table(:users)
|
249
386
|
.define_model do
|
@@ -257,6 +394,38 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
|
|
257
394
|
refute_problems
|
258
395
|
end
|
259
396
|
|
397
|
+
def test_polymorphic_has_one_without_index
|
398
|
+
create_table(:users)
|
399
|
+
.define_model do
|
400
|
+
has_one :account, as: :accountable
|
401
|
+
end
|
402
|
+
|
403
|
+
create_table(:accounts) do |t|
|
404
|
+
t.belongs_to :accountable, polymorphic: true, index: false
|
405
|
+
end.define_model do
|
406
|
+
belongs_to :accountable, polymorphic: true
|
407
|
+
end
|
408
|
+
|
409
|
+
assert_problems(<<~OUTPUT)
|
410
|
+
add a unique index on accounts(accountable_type, accountable_id) - using `has_one` in TransientRecord::Models::User without an index can lead to duplicates
|
411
|
+
OUTPUT
|
412
|
+
end
|
413
|
+
|
414
|
+
def test_polymorphic_has_one_with_index
|
415
|
+
create_table(:users)
|
416
|
+
.define_model do
|
417
|
+
has_one :account, as: :accountable
|
418
|
+
end
|
419
|
+
|
420
|
+
create_table(:accounts) do |t|
|
421
|
+
t.belongs_to :accountable, polymorphic: true, index: { unique: true }
|
422
|
+
end.define_model do
|
423
|
+
belongs_to :accountable, polymorphic: true
|
424
|
+
end
|
425
|
+
|
426
|
+
refute_problems
|
427
|
+
end
|
428
|
+
|
260
429
|
def test_config_ignore_models
|
261
430
|
create_table(:users) do |t|
|
262
431
|
t.string :email
|
@@ -25,6 +25,11 @@ class ActiveRecordDoctor::Detectors::ShortPrimaryKeyTypeTest < Minitest::Test
|
|
25
25
|
OUTPUT
|
26
26
|
end
|
27
27
|
|
28
|
+
def test_non_integer_and_non_uuid_primary_key_is_not_reported
|
29
|
+
create_table(:companies, id: :string, primary_key: :uuid)
|
30
|
+
refute_problems
|
31
|
+
end
|
32
|
+
|
28
33
|
def test_long_integer_primary_key_is_not_reported
|
29
34
|
create_table(:companies, id: :bigint)
|
30
35
|
refute_problems
|
@@ -10,10 +10,48 @@ class ActiveRecordDoctor::Detectors::UnindexedForeignKeysTest < Minitest::Test
|
|
10
10
|
end
|
11
11
|
|
12
12
|
assert_problems(<<~OUTPUT)
|
13
|
-
add an index on users
|
13
|
+
add an index on users(company_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
|
14
14
|
OUTPUT
|
15
15
|
end
|
16
16
|
|
17
|
+
def test_unindexed_foreign_key_with_nonstandard_name_is_reported
|
18
|
+
skip("MySQL always indexes foreign keys") if mysql?
|
19
|
+
|
20
|
+
create_table(:companies)
|
21
|
+
create_table(:users) do |t|
|
22
|
+
t.integer :company
|
23
|
+
t.foreign_key :companies, column: :company
|
24
|
+
end
|
25
|
+
|
26
|
+
assert_problems(<<~OUTPUT)
|
27
|
+
add an index on users(company) - foreign keys are often used in database lookups and should be indexed for performance reasons
|
28
|
+
OUTPUT
|
29
|
+
end
|
30
|
+
|
31
|
+
def test_unindexed_polymorphic_foreign_key_is_reported
|
32
|
+
create_table(:notes) do |t|
|
33
|
+
t.integer :notable_id
|
34
|
+
t.string :notable_type
|
35
|
+
end
|
36
|
+
|
37
|
+
assert_problems(<<~OUTPUT)
|
38
|
+
add an index on notes(notable_type, notable_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
|
39
|
+
OUTPUT
|
40
|
+
end
|
41
|
+
|
42
|
+
def test_indexed_polymorphic_foreign_key_is_not_reported
|
43
|
+
create_table(:notes) do |t|
|
44
|
+
t.string :title
|
45
|
+
t.integer :notable_id
|
46
|
+
t.string :notable_type
|
47
|
+
|
48
|
+
# Includes additional column except `notable`
|
49
|
+
t.index [:notable_type, :notable_id, :title]
|
50
|
+
end
|
51
|
+
|
52
|
+
refute_problems
|
53
|
+
end
|
54
|
+
|
17
55
|
def test_indexed_foreign_key_is_not_reported
|
18
56
|
create_table(:companies)
|
19
57
|
create_table(:users) do |t|
|
@@ -8,6 +8,10 @@ class ActiveRecordDoctor::AddIndexesGeneratorTest < Minitest::Test
|
|
8
8
|
TIMESTAMP = Time.new(2021, 2, 1, 13, 15, 30)
|
9
9
|
|
10
10
|
def test_create_migrations
|
11
|
+
create_table(:notes) do |t|
|
12
|
+
t.integer :notable_id, null: false
|
13
|
+
t.string :notable_type, null: false
|
14
|
+
end
|
11
15
|
create_table(:users) do |t|
|
12
16
|
t.integer :organization_id, null: false
|
13
17
|
t.integer :account_id, null: false
|
@@ -21,9 +25,10 @@ class ActiveRecordDoctor::AddIndexesGeneratorTest < Minitest::Test
|
|
21
25
|
|
22
26
|
path = File.join(dir, "indexes.txt")
|
23
27
|
File.write(path, <<~INDEXES)
|
24
|
-
add an index on users
|
25
|
-
add an index on users
|
26
|
-
add an index on organizations
|
28
|
+
add an index on users(organization_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
|
29
|
+
add an index on users(account_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
|
30
|
+
add an index on organizations(owner_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
|
31
|
+
add an index on notes(notable_type, notable_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
|
27
32
|
INDEXES
|
28
33
|
|
29
34
|
capture_io do
|
@@ -36,25 +41,30 @@ class ActiveRecordDoctor::AddIndexesGeneratorTest < Minitest::Test
|
|
36
41
|
load(File.join("db", "migrate", "20210201131531_index_foreign_keys_in_organizations.rb"))
|
37
42
|
IndexForeignKeysInOrganizations.migrate(:up)
|
38
43
|
|
44
|
+
load(File.join("db", "migrate", "20210201131532_index_foreign_keys_in_notes.rb"))
|
45
|
+
IndexForeignKeysInNotes.migrate(:up)
|
46
|
+
|
39
47
|
::Object.send(:remove_const, :IndexForeignKeysInUsers)
|
40
48
|
::Object.send(:remove_const, :IndexForeignKeysInOrganizations)
|
49
|
+
::Object.send(:remove_const, :IndexForeignKeysInNotes)
|
41
50
|
end
|
42
51
|
end
|
43
52
|
|
44
53
|
assert_indexes([
|
54
|
+
["notes", ["notable_type", "notable_id"]],
|
45
55
|
["users", ["organization_id"]],
|
46
56
|
["users", ["account_id"]],
|
47
57
|
["organizations", ["owner_id"]]
|
48
58
|
])
|
49
59
|
|
50
|
-
assert_equal(
|
60
|
+
assert_equal(5, Dir.entries("./db/migrate").size)
|
51
61
|
end
|
52
62
|
end
|
53
63
|
|
54
64
|
def test_create_migrations_raises_when_malformed_inpout
|
55
65
|
Tempfile.create do |file|
|
56
66
|
file.write(<<~INDEXES)
|
57
|
-
add an index on users
|
67
|
+
add an index on users() - foreign keys are often used in database lookups and should be indexed for performance reasons
|
58
68
|
INDEXES
|
59
69
|
file.flush
|
60
70
|
|
@@ -95,7 +105,7 @@ class ActiveRecordDoctor::AddIndexesGeneratorTest < Minitest::Test
|
|
95
105
|
|
96
106
|
path = File.join(dir, "indexes.txt")
|
97
107
|
File.write(path, <<~INDEXES)
|
98
|
-
add an index on organizations_migrated_from_legacy_app
|
108
|
+
add an index on organizations_migrated_from_legacy_app(legacy_owner_id_compatible_with_v1_to_v8) - foreign keys are often used in database lookups and should be indexed for performance reasons
|
99
109
|
INDEXES
|
100
110
|
|
101
111
|
capture_io do
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_record_doctor
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.12.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Greg Navis
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-07-27 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -58,14 +58,14 @@ dependencies:
|
|
58
58
|
requirements:
|
59
59
|
- - "~>"
|
60
60
|
- !ruby/object:Gem::Version
|
61
|
-
version: 1.
|
61
|
+
version: 1.3.0
|
62
62
|
type: :development
|
63
63
|
prerelease: false
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
65
65
|
requirements:
|
66
66
|
- - "~>"
|
67
67
|
- !ruby/object:Gem::Version
|
68
|
-
version: 1.
|
68
|
+
version: 1.3.0
|
69
69
|
- !ruby/object:Gem::Dependency
|
70
70
|
name: railties
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|
@@ -159,6 +159,7 @@ files:
|
|
159
159
|
- lib/active_record_doctor/railtie.rb
|
160
160
|
- lib/active_record_doctor/rake/task.rb
|
161
161
|
- lib/active_record_doctor/runner.rb
|
162
|
+
- lib/active_record_doctor/utils.rb
|
162
163
|
- lib/active_record_doctor/version.rb
|
163
164
|
- lib/generators/active_record_doctor/add_indexes/USAGE
|
164
165
|
- lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb
|
@@ -201,7 +202,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
201
202
|
- !ruby/object:Gem::Version
|
202
203
|
version: '0'
|
203
204
|
requirements: []
|
204
|
-
rubygems_version: 3.
|
205
|
+
rubygems_version: 3.4.10
|
205
206
|
signing_key:
|
206
207
|
specification_version: 4
|
207
208
|
summary: Identify database issues before they hit production.
|