active_record_doctor 1.5.0 → 1.6.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 +73 -6
- data/lib/active_record_doctor/printers/io_printer.rb +35 -6
- data/lib/active_record_doctor/railtie.rb +1 -1
- data/lib/active_record_doctor/tasks.rb +3 -0
- data/lib/active_record_doctor/tasks/base.rb +64 -0
- data/lib/active_record_doctor/tasks/extraneous_indexes.rb +4 -27
- data/lib/active_record_doctor/tasks/missing_foreign_keys.rb +6 -29
- data/lib/active_record_doctor/tasks/missing_non_null_constraint.rb +42 -0
- data/lib/active_record_doctor/tasks/missing_presence_validation.rb +39 -0
- data/lib/active_record_doctor/tasks/missing_unique_indexes.rb +51 -0
- data/lib/active_record_doctor/tasks/undefined_table_references.rb +6 -22
- data/lib/active_record_doctor/tasks/unindexed_deleted_at.rb +4 -25
- data/lib/active_record_doctor/tasks/unindexed_foreign_keys.rb +6 -29
- data/lib/active_record_doctor/version.rb +1 -1
- data/lib/tasks/active_record_doctor.rake +31 -0
- data/test/active_record_doctor/printers/io_printer_test.rb +4 -4
- data/test/active_record_doctor/tasks/extraneous_indexes_test.rb +70 -16
- data/test/active_record_doctor/tasks/missing_foreign_keys_test.rb +16 -8
- data/test/active_record_doctor/tasks/missing_non_null_constraint_test.rb +89 -0
- data/test/active_record_doctor/tasks/missing_presence_validation_test.rb +49 -0
- data/test/active_record_doctor/tasks/missing_unique_indexes_test.rb +95 -0
- data/test/active_record_doctor/tasks/unindexed_deleted_at_test.rb +23 -8
- data/test/active_record_doctor/tasks/unindexed_foreign_keys_test.rb +16 -8
- data/test/dummy/app/models/application_record.rb +1 -1
- data/test/dummy/db/schema.rb +1 -50
- data/test/dummy/log/development.log +38 -498
- data/test/dummy/log/test.log +54108 -1571
- data/test/support/assertions.rb +11 -0
- data/test/support/forking_test.rb +28 -0
- data/test/support/temping.rb +25 -0
- data/test/test_helper.rb +0 -7
- metadata +34 -27
- data/lib/active_record_doctor/compatibility.rb +0 -11
- data/lib/tasks/active_record_doctor_tasks.rake +0 -27
- data/test/active_record_doctor/tasks/undefined_table_references_test.rb +0 -19
- data/test/dummy/app/models/comment.rb +0 -3
- data/test/dummy/app/models/contract.rb +0 -3
- data/test/dummy/app/models/employer.rb +0 -2
- data/test/dummy/app/models/profile.rb +0 -2
- data/test/dummy/app/models/user.rb +0 -3
- data/test/dummy/db/migrate/20160213101213_create_employers.rb +0 -15
- data/test/dummy/db/migrate/20160213101221_create_users.rb +0 -23
- data/test/dummy/db/migrate/20160213101232_create_profiles.rb +0 -15
- data/test/dummy/db/migrate/20160604081452_create_comments.rb +0 -11
- data/test/support/spy_printer.rb +0 -52
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 01e06f004eba174cadece575f77fe83381f53133
|
4
|
+
data.tar.gz: d835060741b338b07a3f87aef33eff707ef32f98
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e07f92a2fd4b200fd5d9e0ef50e1b5a516347b8216080071648bca31c6e72683ceb01d85e125002e4aa0cd5f48a8020ca43097488a67906c15be443e68783da4
|
7
|
+
data.tar.gz: 2faca055adba20d77aac44658adb35d40243f491b533a088e5ca44615d16f36e4cee7deca99b1a37607127c5f2bc276e5dac0aa56c34f0b5aa87b611667afd3e
|
data/README.md
CHANGED
@@ -8,6 +8,9 @@ can:
|
|
8
8
|
* detect unindexed `deleted_at` columns
|
9
9
|
* detect missing foreign key constraints
|
10
10
|
* detect models referencing undefined tables
|
11
|
+
* detect uniqueness validations not backed by an unique index
|
12
|
+
* detect missing non-`NULL` constraints
|
13
|
+
* detect missing presence validations
|
11
14
|
|
12
15
|
More features coming soon!
|
13
16
|
|
@@ -42,7 +45,7 @@ three-step process:
|
|
42
45
|
1. Generate a list of unindexed foreign keys by running
|
43
46
|
|
44
47
|
```bash
|
45
|
-
rake active_record_doctor:unindexed_foreign_keys > unindexed_foreign_keys.txt
|
48
|
+
bundle exec rake active_record_doctor:unindexed_foreign_keys > unindexed_foreign_keys.txt
|
46
49
|
```
|
47
50
|
|
48
51
|
2. Remove columns that should _not_ be indexed from `unindexed_foreign_keys.txt`
|
@@ -58,7 +61,7 @@ three-step process:
|
|
58
61
|
4. Run the migrations
|
59
62
|
|
60
63
|
```bash
|
61
|
-
rake db:migrate
|
64
|
+
bundle exec rake db:migrate
|
62
65
|
```
|
63
66
|
|
64
67
|
### Removing Extraneous Indexes
|
@@ -83,7 +86,7 @@ To discover such indexes automatically just follow these steps:
|
|
83
86
|
1. List extraneous indexes by running:
|
84
87
|
|
85
88
|
```bash
|
86
|
-
rake active_record_doctor:extraneous_indexes
|
89
|
+
bundle exec rake active_record_doctor:extraneous_indexes
|
87
90
|
```
|
88
91
|
|
89
92
|
2. Confirm that each of the indexes can be indeed dropped.
|
@@ -113,7 +116,7 @@ of the time they should only cover columns satisfying `deleted_at IS NULL`.
|
|
113
116
|
`deleted_at` column. Just run:
|
114
117
|
|
115
118
|
```
|
116
|
-
rake active_record_doctor:
|
119
|
+
bundle exec rake active_record_doctor:unindexed_deleted_at
|
117
120
|
```
|
118
121
|
|
119
122
|
This will print a list of indexes that don't have the `deleted_at IS NULL`
|
@@ -134,7 +137,7 @@ add the constraint; for now, it's your job). You can obtain the list of foreign
|
|
134
137
|
keys with the following command:
|
135
138
|
|
136
139
|
```bash
|
137
|
-
rake active_record_doctor:missing_foreign_keys
|
140
|
+
bundle exec rake active_record_doctor:missing_foreign_keys
|
138
141
|
```
|
139
142
|
|
140
143
|
The output will look like:
|
@@ -170,7 +173,7 @@ before they hit production.
|
|
170
173
|
The only think you need to do is run:
|
171
174
|
|
172
175
|
```
|
173
|
-
rake active_record_doctor:undefined_table_references
|
176
|
+
bundle exec rake active_record_doctor:undefined_table_references
|
174
177
|
```
|
175
178
|
|
176
179
|
If there a model references an undefined table then you'll see a message like
|
@@ -184,6 +187,70 @@ The following models reference undefined tables:
|
|
184
187
|
On top of that `rake` will exit with status code of 1. This allows you to use
|
185
188
|
this check as part of your Continuous Integration pipeline.
|
186
189
|
|
190
|
+
### Detecting Uniqueness Validations not Backed by an Index
|
191
|
+
|
192
|
+
A model-level uniqueness validations should be backed by a database index in
|
193
|
+
order to be robust. Otherwise you risk inserting duplicate values under heavy
|
194
|
+
load.
|
195
|
+
|
196
|
+
In order to detect such validations run:
|
197
|
+
|
198
|
+
```
|
199
|
+
bundle exec rake active_record_doctor:missing_unique_indexes
|
200
|
+
```
|
201
|
+
|
202
|
+
If there are such indexes then the command will print:
|
203
|
+
|
204
|
+
```
|
205
|
+
The following indexes should be created to back model-level uniqueness validations:
|
206
|
+
users: email
|
207
|
+
```
|
208
|
+
|
209
|
+
This means that you should create a unique index on `users.email`.
|
210
|
+
|
211
|
+
### Detecting Missing Non-`NULL` Constraints
|
212
|
+
|
213
|
+
If there's an unconditional presence validation on a column then it should be
|
214
|
+
marked as non-`NULL`-able at the database level.
|
215
|
+
|
216
|
+
In order to detect columns whose presence is required but that are marked
|
217
|
+
`null: true` in the database run the following command:
|
218
|
+
|
219
|
+
```
|
220
|
+
bundle exec rake active_record_doctor:missing_non_null_constraint
|
221
|
+
```
|
222
|
+
|
223
|
+
The output of the command is similar to:
|
224
|
+
|
225
|
+
```
|
226
|
+
The following columns should be marked as `null: false`:
|
227
|
+
users: name
|
228
|
+
|
229
|
+
```
|
230
|
+
|
231
|
+
You can mark the columns mentioned in the output as `null: false` by creating a
|
232
|
+
migration and calling `change_column_null`.
|
233
|
+
|
234
|
+
### Detecting Missing Presence Validations
|
235
|
+
|
236
|
+
If a column is marked as `null: false` then it's likely it should have the
|
237
|
+
corresponding presence validator.
|
238
|
+
|
239
|
+
In order to detect models lacking these validations run:
|
240
|
+
|
241
|
+
```
|
242
|
+
bundle exec rake active_record_doctor:missing_presence_validation
|
243
|
+
```
|
244
|
+
|
245
|
+
The output of the command looks like this:
|
246
|
+
|
247
|
+
```
|
248
|
+
The following models and columns should have presence validations:
|
249
|
+
User: email, name
|
250
|
+
```
|
251
|
+
|
252
|
+
This means `User` should have a presence validator on `email` and `name`.
|
253
|
+
|
187
254
|
## Author
|
188
255
|
|
189
256
|
This gem is developed and maintained by [Greg Navis](http://www.gregnavis.com).
|
@@ -1,17 +1,17 @@
|
|
1
1
|
module ActiveRecordDoctor
|
2
2
|
module Printers
|
3
3
|
class IOPrinter
|
4
|
-
def initialize(io
|
4
|
+
def initialize(io = STDOUT)
|
5
5
|
@io = io
|
6
6
|
end
|
7
7
|
|
8
|
-
def
|
8
|
+
def unindexed_foreign_keys(unindexed_foreign_keys)
|
9
9
|
@io.puts(unindexed_foreign_keys.sort.map do |table, columns|
|
10
10
|
"#{table} #{columns.sort.join(' ')}"
|
11
11
|
end.join("\n"))
|
12
12
|
end
|
13
13
|
|
14
|
-
def
|
14
|
+
def extraneous_indexes(extraneous_indexes)
|
15
15
|
if extraneous_indexes.empty?
|
16
16
|
@io.puts("No indexes are extraneous.")
|
17
17
|
else
|
@@ -30,13 +30,13 @@ module ActiveRecordDoctor
|
|
30
30
|
end
|
31
31
|
end
|
32
32
|
|
33
|
-
def
|
33
|
+
def missing_foreign_keys(missing_foreign_keys)
|
34
34
|
@io.puts(missing_foreign_keys.sort.map do |table, columns|
|
35
35
|
"#{table} #{columns.sort.join(' ')}"
|
36
36
|
end.join("\n"))
|
37
37
|
end
|
38
38
|
|
39
|
-
def
|
39
|
+
def undefined_table_references(models)
|
40
40
|
return if models.empty?
|
41
41
|
|
42
42
|
@io.puts('The following models reference undefined tables:')
|
@@ -45,7 +45,7 @@ module ActiveRecordDoctor
|
|
45
45
|
end
|
46
46
|
end
|
47
47
|
|
48
|
-
def
|
48
|
+
def unindexed_deleted_at(indexes)
|
49
49
|
return if indexes.empty?
|
50
50
|
|
51
51
|
@io.puts('The following indexes should include `deleted_at IS NULL`:')
|
@@ -53,6 +53,35 @@ module ActiveRecordDoctor
|
|
53
53
|
@io.puts(" #{index}")
|
54
54
|
end
|
55
55
|
end
|
56
|
+
|
57
|
+
def missing_unique_indexes(indexes)
|
58
|
+
return if indexes.empty?
|
59
|
+
|
60
|
+
@io.puts('The following indexes should be created to back model-level uniqueness validations:')
|
61
|
+
indexes.each do |table, arrays_of_columns|
|
62
|
+
arrays_of_columns.each do |columns|
|
63
|
+
@io.puts(" #{table}: #{columns.join(', ')}")
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def missing_presence_validation(missing_presence_validators)
|
69
|
+
return if missing_presence_validators.empty?
|
70
|
+
|
71
|
+
@io.puts('The following models and columns should have presence validations:')
|
72
|
+
missing_presence_validators.each do |model_name, array_of_columns|
|
73
|
+
@io.puts(" #{model_name}: #{array_of_columns.join(', ')}")
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def missing_non_null_constraint(missing_non_null_constraints)
|
78
|
+
return if missing_non_null_constraints.empty?
|
79
|
+
|
80
|
+
@io.puts('The following columns should be marked as `null: false`:')
|
81
|
+
missing_non_null_constraints.each do |table, columns|
|
82
|
+
@io.puts(" #{table}: #{columns.join(', ')}")
|
83
|
+
end
|
84
|
+
end
|
56
85
|
end
|
57
86
|
end
|
58
87
|
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require "active_record_doctor/printers/io_printer"
|
2
|
+
|
3
|
+
module ActiveRecordDoctor
|
4
|
+
module Tasks
|
5
|
+
class Base
|
6
|
+
def self.run
|
7
|
+
new.run
|
8
|
+
end
|
9
|
+
|
10
|
+
def initialize(printer = ActiveRecordDoctor::Printers::IOPrinter.new)
|
11
|
+
@printer = printer
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def success(result)
|
17
|
+
[result, true]
|
18
|
+
end
|
19
|
+
|
20
|
+
def connection
|
21
|
+
@connection ||= ActiveRecord::Base.connection
|
22
|
+
end
|
23
|
+
|
24
|
+
def indexes(table_name)
|
25
|
+
connection.indexes(table_name)
|
26
|
+
end
|
27
|
+
|
28
|
+
def tables
|
29
|
+
@tables ||=
|
30
|
+
if Rails::VERSION::MAJOR == 5
|
31
|
+
connection.data_sources
|
32
|
+
else
|
33
|
+
connection.tables
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def hash_from_pairs(pairs)
|
38
|
+
Hash[*pairs.flatten(1)]
|
39
|
+
end
|
40
|
+
|
41
|
+
def eager_load!
|
42
|
+
# We call GC.start to make the test suite work. It's (probably) not
|
43
|
+
# needed for use during development. However, if we remove it then the
|
44
|
+
# test suite will start accumulating temporary model classes in the
|
45
|
+
# object space. Running the garbage collector gets rid of them.
|
46
|
+
GC.start
|
47
|
+
|
48
|
+
Rails.application.eager_load!
|
49
|
+
end
|
50
|
+
|
51
|
+
def models
|
52
|
+
descendants(ActiveRecord::Base)
|
53
|
+
end
|
54
|
+
|
55
|
+
def descendants(superclass)
|
56
|
+
superclass.descendants
|
57
|
+
end
|
58
|
+
|
59
|
+
def descendant?(klass, superclass)
|
60
|
+
!klass.nil? && (klass.superclass == superclass || descendant?(klass.superclass, superclass))
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -1,29 +1,14 @@
|
|
1
|
-
require "active_record_doctor/
|
2
|
-
require "active_record_doctor/printers/io_printer"
|
1
|
+
require "active_record_doctor/tasks/base"
|
3
2
|
|
4
3
|
module ActiveRecordDoctor
|
5
4
|
module Tasks
|
6
|
-
class ExtraneousIndexes
|
7
|
-
include Compatibility
|
8
|
-
|
9
|
-
def self.run
|
10
|
-
new.run
|
11
|
-
end
|
12
|
-
|
13
|
-
def initialize(printer: ActiveRecordDoctor::Printers::IOPrinter.new)
|
14
|
-
@printer = printer
|
15
|
-
end
|
16
|
-
|
5
|
+
class ExtraneousIndexes < Base
|
17
6
|
def run
|
18
|
-
|
7
|
+
success(subindexes_of_multi_column_indexes + indexed_primary_keys)
|
19
8
|
end
|
20
9
|
|
21
10
|
private
|
22
11
|
|
23
|
-
def extraneous_indexes
|
24
|
-
subindexes_of_multi_column_indexes + indexed_primary_keys
|
25
|
-
end
|
26
|
-
|
27
12
|
def subindexes_of_multi_column_indexes
|
28
13
|
tables.reject do |table|
|
29
14
|
"schema_migrations" == table
|
@@ -90,15 +75,7 @@ module ActiveRecordDoctor
|
|
90
75
|
end
|
91
76
|
|
92
77
|
def indexes(table_name)
|
93
|
-
|
94
|
-
end
|
95
|
-
|
96
|
-
def tables
|
97
|
-
@tables ||= connection_tables
|
98
|
-
end
|
99
|
-
|
100
|
-
def connection
|
101
|
-
@connection ||= ActiveRecord::Base.connection
|
78
|
+
super.select { |index| index.columns.kind_of?(Array) }
|
102
79
|
end
|
103
80
|
end
|
104
81
|
end
|
@@ -1,27 +1,10 @@
|
|
1
|
-
require "active_record_doctor/
|
2
|
-
require "active_record_doctor/printers/io_printer"
|
1
|
+
require "active_record_doctor/tasks/base"
|
3
2
|
|
4
3
|
module ActiveRecordDoctor
|
5
4
|
module Tasks
|
6
|
-
class MissingForeignKeys
|
7
|
-
include Compatibility
|
8
|
-
|
9
|
-
def self.run
|
10
|
-
new.run
|
11
|
-
end
|
12
|
-
|
13
|
-
def initialize(printer: ActiveRecordDoctor::Printers::IOPrinter.new)
|
14
|
-
@printer = printer
|
15
|
-
end
|
16
|
-
|
5
|
+
class MissingForeignKeys < Base
|
17
6
|
def run
|
18
|
-
|
19
|
-
end
|
20
|
-
|
21
|
-
private
|
22
|
-
|
23
|
-
def missing_foreign_keys
|
24
|
-
hash_from_pairs(connection_tables.select do |table|
|
7
|
+
success(hash_from_pairs(tables.select do |table|
|
25
8
|
"schema_migrations" != table
|
26
9
|
end.map do |table|
|
27
10
|
[
|
@@ -37,9 +20,11 @@ module ActiveRecordDoctor
|
|
37
20
|
]
|
38
21
|
end.select do |table, columns|
|
39
22
|
!columns.empty?
|
40
|
-
end)
|
23
|
+
end))
|
41
24
|
end
|
42
25
|
|
26
|
+
private
|
27
|
+
|
43
28
|
def id?(table, column)
|
44
29
|
column.name.end_with?("_id")
|
45
30
|
end
|
@@ -56,14 +41,6 @@ module ActiveRecordDoctor
|
|
56
41
|
another_column.name == type_column_name
|
57
42
|
end
|
58
43
|
end
|
59
|
-
|
60
|
-
def connection
|
61
|
-
@connection ||= ActiveRecord::Base.connection
|
62
|
-
end
|
63
|
-
|
64
|
-
def hash_from_pairs(pairs)
|
65
|
-
Hash[*pairs.flatten(1)]
|
66
|
-
end
|
67
44
|
end
|
68
45
|
end
|
69
46
|
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require "active_record_doctor/tasks/base"
|
2
|
+
|
3
|
+
module ActiveRecordDoctor
|
4
|
+
module Tasks
|
5
|
+
class MissingNonNullConstraint < Base
|
6
|
+
def run
|
7
|
+
eager_load!
|
8
|
+
|
9
|
+
success(hash_from_pairs(models.reject do |model|
|
10
|
+
model.table_name.nil? || model.table_name == 'schema_migrations'
|
11
|
+
end.map do |model|
|
12
|
+
[
|
13
|
+
model.table_name,
|
14
|
+
connection.columns(model.table_name).select do |column|
|
15
|
+
validator_needed?(model, column) &&
|
16
|
+
has_mandatory_presence_validator?(model, column) &&
|
17
|
+
column.null
|
18
|
+
end.map(&:name)
|
19
|
+
]
|
20
|
+
end.reject do |model_name, columns|
|
21
|
+
columns.empty?
|
22
|
+
end))
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def validator_needed?(model, column)
|
28
|
+
![model.primary_key, 'created_at', 'updated_at'].include?(column.name)
|
29
|
+
end
|
30
|
+
|
31
|
+
def has_mandatory_presence_validator?(model, column)
|
32
|
+
model.validators.any? do |validator|
|
33
|
+
validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
|
34
|
+
validator.attributes.include?(column.name.to_sym) &&
|
35
|
+
!validator.options[:allow_nil] &&
|
36
|
+
!validator.options[:if] &&
|
37
|
+
!validator.options[:unless]
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|