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.
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