active_record_doctor 1.5.0 → 1.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +73 -6
  3. data/lib/active_record_doctor/printers/io_printer.rb +35 -6
  4. data/lib/active_record_doctor/railtie.rb +1 -1
  5. data/lib/active_record_doctor/tasks.rb +3 -0
  6. data/lib/active_record_doctor/tasks/base.rb +64 -0
  7. data/lib/active_record_doctor/tasks/extraneous_indexes.rb +4 -27
  8. data/lib/active_record_doctor/tasks/missing_foreign_keys.rb +6 -29
  9. data/lib/active_record_doctor/tasks/missing_non_null_constraint.rb +42 -0
  10. data/lib/active_record_doctor/tasks/missing_presence_validation.rb +39 -0
  11. data/lib/active_record_doctor/tasks/missing_unique_indexes.rb +51 -0
  12. data/lib/active_record_doctor/tasks/undefined_table_references.rb +6 -22
  13. data/lib/active_record_doctor/tasks/unindexed_deleted_at.rb +4 -25
  14. data/lib/active_record_doctor/tasks/unindexed_foreign_keys.rb +6 -29
  15. data/lib/active_record_doctor/version.rb +1 -1
  16. data/lib/tasks/active_record_doctor.rake +31 -0
  17. data/test/active_record_doctor/printers/io_printer_test.rb +4 -4
  18. data/test/active_record_doctor/tasks/extraneous_indexes_test.rb +70 -16
  19. data/test/active_record_doctor/tasks/missing_foreign_keys_test.rb +16 -8
  20. data/test/active_record_doctor/tasks/missing_non_null_constraint_test.rb +89 -0
  21. data/test/active_record_doctor/tasks/missing_presence_validation_test.rb +49 -0
  22. data/test/active_record_doctor/tasks/missing_unique_indexes_test.rb +95 -0
  23. data/test/active_record_doctor/tasks/unindexed_deleted_at_test.rb +23 -8
  24. data/test/active_record_doctor/tasks/unindexed_foreign_keys_test.rb +16 -8
  25. data/test/dummy/app/models/application_record.rb +1 -1
  26. data/test/dummy/db/schema.rb +1 -50
  27. data/test/dummy/log/development.log +38 -498
  28. data/test/dummy/log/test.log +54108 -1571
  29. data/test/support/assertions.rb +11 -0
  30. data/test/support/forking_test.rb +28 -0
  31. data/test/support/temping.rb +25 -0
  32. data/test/test_helper.rb +0 -7
  33. metadata +34 -27
  34. data/lib/active_record_doctor/compatibility.rb +0 -11
  35. data/lib/tasks/active_record_doctor_tasks.rake +0 -27
  36. data/test/active_record_doctor/tasks/undefined_table_references_test.rb +0 -19
  37. data/test/dummy/app/models/comment.rb +0 -3
  38. data/test/dummy/app/models/contract.rb +0 -3
  39. data/test/dummy/app/models/employer.rb +0 -2
  40. data/test/dummy/app/models/profile.rb +0 -2
  41. data/test/dummy/app/models/user.rb +0 -3
  42. data/test/dummy/db/migrate/20160213101213_create_employers.rb +0 -15
  43. data/test/dummy/db/migrate/20160213101221_create_users.rb +0 -23
  44. data/test/dummy/db/migrate/20160213101232_create_profiles.rb +0 -15
  45. data/test/dummy/db/migrate/20160604081452_create_comments.rb +0 -11
  46. data/test/support/spy_printer.rb +0 -52
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: dd6b18b16b7a356e1760befd1d59d7f2b7a77ed3
4
- data.tar.gz: ea1c526b3080d7c8ee5ae4e4eb9a833e3b797cc4
3
+ metadata.gz: 01e06f004eba174cadece575f77fe83381f53133
4
+ data.tar.gz: d835060741b338b07a3f87aef33eff707ef32f98
5
5
  SHA512:
6
- metadata.gz: 91035e0b5fa47e0d4e5b84a42f53573efc1c333555978a5130ecf05695b12aa17ffcf542b7e0d4c253332ad52b2688f5645e138bd755e3649ea88b0574e41c63
7
- data.tar.gz: 137b3c1a9fd054e0b1af8fd1aad40f5b79ce068b0b0db458ca63d0f64cdf4c23342131a775a5f5148aec34e8c8ff2c32fd1e2a31b1bd86a69a3271c07e34b104
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:unindexed_soft_delete
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: STDOUT)
4
+ def initialize(io = STDOUT)
5
5
  @io = io
6
6
  end
7
7
 
8
- def print_unindexed_foreign_keys(unindexed_foreign_keys)
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 print_extraneous_indexes(extraneous_indexes)
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 print_missing_foreign_keys(missing_foreign_keys)
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 print_undefined_table_references(models)
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 print_unindexed_deleted_at(indexes)
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
@@ -1,7 +1,7 @@
1
1
  module ActiveRecordDoctor
2
2
  class Railtie < Rails::Railtie
3
3
  rake_tasks do
4
- load "tasks/active_record_doctor_tasks.rake"
4
+ load "tasks/active_record_doctor.rake"
5
5
  end
6
6
  end
7
7
  end
@@ -1,4 +1,7 @@
1
1
  module ActiveRecordDoctor
2
2
  module Tasks
3
+ def self.all
4
+ ActiveRecordDoctor::Tasks::Base.subclasses
5
+ end
3
6
  end
4
7
  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/compatibility"
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
- @printer.print_extraneous_indexes(extraneous_indexes)
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
- @connection.indexes(table_name)
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/compatibility"
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
- @printer.print_missing_foreign_keys(missing_foreign_keys)
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