active_record_doctor 1.11.0 → 1.12.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 +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.
|