active_record_doctor 1.7.2 → 1.8.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +29 -0
  3. data/lib/active_record_doctor.rb +16 -12
  4. data/lib/active_record_doctor/detectors.rb +13 -0
  5. data/lib/active_record_doctor/detectors/base.rb +64 -0
  6. data/lib/active_record_doctor/{tasks → detectors}/extraneous_indexes.rb +11 -7
  7. data/lib/active_record_doctor/{tasks → detectors}/incorrect_boolean_presence_validation.rb +9 -6
  8. data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +71 -0
  9. data/lib/active_record_doctor/{tasks → detectors}/missing_foreign_keys.rb +13 -10
  10. data/lib/active_record_doctor/{tasks → detectors}/missing_non_null_constraint.rb +11 -7
  11. data/lib/active_record_doctor/{tasks → detectors}/missing_presence_validation.rb +11 -8
  12. data/lib/active_record_doctor/{tasks → detectors}/missing_unique_indexes.rb +8 -4
  13. data/lib/active_record_doctor/{tasks → detectors}/undefined_table_references.rb +11 -12
  14. data/lib/active_record_doctor/{tasks → detectors}/unindexed_deleted_at.rb +12 -6
  15. data/lib/active_record_doctor/{tasks → detectors}/unindexed_foreign_keys.rb +13 -10
  16. data/lib/active_record_doctor/printers.rb +3 -1
  17. data/lib/active_record_doctor/printers/io_printer.rb +63 -35
  18. data/lib/active_record_doctor/railtie.rb +2 -0
  19. data/lib/active_record_doctor/task.rb +28 -0
  20. data/lib/active_record_doctor/version.rb +3 -1
  21. data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +15 -11
  22. data/lib/tasks/active_record_doctor.rake +25 -25
  23. data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +67 -0
  24. data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +36 -0
  25. data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +117 -0
  26. data/test/active_record_doctor/detectors/missing_foreign_keys_test.rb +24 -0
  27. data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +102 -0
  28. data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +107 -0
  29. data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +114 -0
  30. data/test/active_record_doctor/detectors/undefined_table_references_test.rb +44 -0
  31. data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +67 -0
  32. data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +26 -0
  33. data/test/active_record_doctor/printers/io_printer_test.rb +14 -9
  34. data/test/model_factory.rb +78 -0
  35. data/test/setup.rb +69 -40
  36. metadata +70 -64
  37. data/lib/active_record_doctor/tasks.rb +0 -10
  38. data/lib/active_record_doctor/tasks/base.rb +0 -86
  39. data/test/active_record_doctor/tasks/extraneous_indexes_test.rb +0 -77
  40. data/test/active_record_doctor/tasks/incorrect_boolean_presence_validation_test.rb +0 -38
  41. data/test/active_record_doctor/tasks/missing_foreign_keys_test.rb +0 -23
  42. data/test/active_record_doctor/tasks/missing_non_null_constraint_test.rb +0 -113
  43. data/test/active_record_doctor/tasks/missing_presence_validation_test.rb +0 -115
  44. data/test/active_record_doctor/tasks/missing_unique_indexes_test.rb +0 -126
  45. data/test/active_record_doctor/tasks/undefined_table_references_test.rb +0 -47
  46. data/test/active_record_doctor/tasks/unindexed_deleted_at_test.rb +0 -59
  47. data/test/active_record_doctor/tasks/unindexed_foreign_keys_test.rb +0 -23
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 649707d9a4a22831b3733778ad05614cc71f8edb704a68e9b18bd784a350daf9
4
- data.tar.gz: 6e0fc8fe22ba882549152203c3a92f3eeebd3effb3f4eead6245001849b9e5de
3
+ metadata.gz: 296a8bbb4a326fd78afb08638435754646e22a4416dc18b02dc66f54198a9130
4
+ data.tar.gz: 5548b71ee3bb95344e34896a7c972cf39ce33f8a4bd1b03a1a2bf02b4081a1dd
5
5
  SHA512:
6
- metadata.gz: 437df56684da87b78b3336337cabe138f5375c54673a5d02da29b06fa9d238e1c9fa34a4cf9973b7bf944a0ef8ebbed5ec9dd9d9d12612f87e16597292ff22aa
7
- data.tar.gz: 3ee83bab9d6dc5c88c4c47d33d69d30ecf373bb6ab4798accfc681066fd0a1a4580295f29a869c69a9ff582c925f7c2cb328b345429a92623942995a0416e1d6
6
+ metadata.gz: 48c447ca18f35ee44db354414980f54d0ee9416ecc81b6b3fef9401afbaeb44b2951f0fabe43c89027d8783430bdbb6b055ca1000779e37d3729a171649ce795
7
+ data.tar.gz: 89ab1d3e4cca4df9c94022e0f11217b41239a3b5f2b3c579d8e466a4fe337909ab48f0b05d28eb5a67fc93afa4afe88a47c7c0ebbd3dc7d45c6d0259db22de40
data/README.md CHANGED
@@ -12,6 +12,7 @@ can:
12
12
  * detect missing non-`NULL` constraints - [`active_record_doctor:missing_non_null_constraint`](#detecting-missing-non-null-constraints)
13
13
  * detect missing presence validations - [`active_record_doctor:missing_presence_validation`](#detecting-missing-presence-validations)
14
14
  * detect incorrect presence validations on boolean columns - [`active_record_doctor:incorrect_boolean_presence_validation`](#detecting-incorrect-presence-validations-on-boolean-columns)
15
+ * detect incorrect values of `dependent` on associations - [`active_record_doctor:incorrect_dependent_option`](#detecting-incorrect-dependent-option-on-associations)
15
16
 
16
17
  More features coming soon!
17
18
 
@@ -284,6 +285,34 @@ This means `active` is validated with `presence: true` instead of
284
285
 
285
286
  This validator skips models whose corresponding database tables don't exist.
286
287
 
288
+ ### Detecting Incorrect `dependent` Option on Associations
289
+
290
+ Cascading model deletions can be sped up with `dependent: :delete_all` (to
291
+ delete all dependent models with one SQL query) but only if the deleted models
292
+ have no callbacks as they're skipped.
293
+
294
+ This can lead to two types of errors:
295
+
296
+ - Using `delete_all` when dependent models define callbacks - they will NOT be
297
+ invoked.
298
+ - Using `destroy` when dependent models define no callbacks - dependent models
299
+ will be loaded one-by-one with no reason
300
+
301
+ In order to detect associations affected by the two aforementioned problems run
302
+ the following command:
303
+
304
+ ```
305
+ bundle exec rake active_record_doctor:incorrect_dependent_option
306
+ ```
307
+
308
+ The output of the command looks like this:
309
+
310
+ ```
311
+ The following associations might be using invalid dependent settings:
312
+ Company: users loads models one-by-one to invoke callbacks even though the related model defines none - consider using `dependent: :delete_all`
313
+ Post: comments skips callbacks that are defined on the associated model - consider changing to `dependent: :destroy` or similar
314
+ ```
315
+
287
316
  ## Ruby and Rails Compatibility Policy
288
317
 
289
318
  The goal of the policy is to ensure proper functioning in reasonable
@@ -1,18 +1,22 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "active_record_doctor/printers"
2
4
  require "active_record_doctor/printers/io_printer"
3
5
  require "active_record_doctor/railtie" if defined?(Rails) && defined?(Rails::Railtie)
4
- require "active_record_doctor/tasks"
5
- require "active_record_doctor/tasks/base"
6
- require "active_record_doctor/tasks/missing_presence_validation"
7
- require "active_record_doctor/tasks/missing_foreign_keys"
8
- require "active_record_doctor/tasks/missing_unique_indexes"
9
- require "active_record_doctor/tasks/incorrect_boolean_presence_validation"
10
- require "active_record_doctor/tasks/extraneous_indexes"
11
- require "active_record_doctor/tasks/unindexed_deleted_at"
12
- require "active_record_doctor/tasks/undefined_table_references"
13
- require "active_record_doctor/tasks/missing_non_null_constraint"
14
- require "active_record_doctor/tasks/unindexed_foreign_keys"
6
+ require "active_record_doctor/detectors"
7
+ require "active_record_doctor/detectors/base"
8
+ require "active_record_doctor/detectors/missing_presence_validation"
9
+ require "active_record_doctor/detectors/missing_foreign_keys"
10
+ require "active_record_doctor/detectors/missing_unique_indexes"
11
+ require "active_record_doctor/detectors/incorrect_boolean_presence_validation"
12
+ require "active_record_doctor/detectors/extraneous_indexes"
13
+ require "active_record_doctor/detectors/unindexed_deleted_at"
14
+ require "active_record_doctor/detectors/undefined_table_references"
15
+ require "active_record_doctor/detectors/missing_non_null_constraint"
16
+ require "active_record_doctor/detectors/unindexed_foreign_keys"
17
+ require "active_record_doctor/detectors/incorrect_dependent_option"
18
+ require "active_record_doctor/task"
15
19
  require "active_record_doctor/version"
16
20
 
17
- module ActiveRecordDoctor
21
+ module ActiveRecordDoctor # :nodoc:
18
22
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/class/subclasses"
5
+
6
+ module ActiveRecordDoctor
7
+ # Container module for all detectors, implemented as separate classes.
8
+ module Detectors
9
+ def self.all
10
+ ActiveRecordDoctor::Detectors::Base.subclasses
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDoctor
4
+ module Detectors
5
+ # Base class for all active_record_doctor detectors.
6
+ class Base
7
+ class << self
8
+ attr_reader :description
9
+
10
+ def run
11
+ new.run
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def problems(problems, options = {})
18
+ [problems, options]
19
+ end
20
+
21
+ def connection
22
+ @connection ||= ActiveRecord::Base.connection
23
+ end
24
+
25
+ def indexes(table_name)
26
+ connection.indexes(table_name)
27
+ end
28
+
29
+ def tables
30
+ connection.tables
31
+ end
32
+
33
+ def table_exists?(table_name)
34
+ connection.table_exists?(table_name)
35
+ end
36
+
37
+ def views
38
+ @views ||=
39
+ if connection.respond_to?(:views)
40
+ connection.views
41
+ elsif connection.adapter_name == "PostgreSQL"
42
+ ActiveRecord::Base.connection.execute(<<-SQL).map { |tuple| tuple.fetch("relname") }
43
+ SELECT c.relname FROM pg_class c WHERE c.relkind IN ('m', 'v')
44
+ SQL
45
+ else # rubocop:disable Style/EmptyElse
46
+ # We don't support this Rails/database combination yet.
47
+ nil
48
+ end
49
+ end
50
+
51
+ def hash_from_pairs(pairs)
52
+ Hash[*pairs.flatten(1)]
53
+ end
54
+
55
+ def eager_load!
56
+ Rails.application.eager_load!
57
+ end
58
+
59
+ def models
60
+ ActiveRecord::Base.descendants
61
+ end
62
+ end
63
+ end
64
+ end
@@ -1,19 +1,23 @@
1
- require "active_record_doctor/tasks/base"
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
2
4
 
3
5
  module ActiveRecordDoctor
4
- module Tasks
6
+ module Detectors
7
+ # Detect indexes whose function can be overtaken by other indexes. For example, an index on columns A, B, and C
8
+ # can also serve as an index on A and A, B.
5
9
  class ExtraneousIndexes < Base
6
- @description = 'Detect extraneous indexes'
10
+ @description = "Detect extraneous indexes"
7
11
 
8
12
  def run
9
- success(subindexes_of_multi_column_indexes + indexed_primary_keys)
13
+ problems(subindexes_of_multi_column_indexes + indexed_primary_keys)
10
14
  end
11
15
 
12
16
  private
13
17
 
14
18
  def subindexes_of_multi_column_indexes
15
19
  tables.reject do |table|
16
- "schema_migrations" == table
20
+ table == "schema_migrations"
17
21
  end.flat_map do |table|
18
22
  indexes = indexes(table)
19
23
  maximum_indexes = indexes.select do |index|
@@ -38,7 +42,7 @@ module ActiveRecordDoctor
38
42
 
39
43
  def indexed_primary_keys
40
44
  @indexed_primary_keys ||= tables.reject do |table|
41
- "schema_migrations" == table
45
+ table == "schema_migrations"
42
46
  end.map do |table|
43
47
  [
44
48
  table,
@@ -77,7 +81,7 @@ module ActiveRecordDoctor
77
81
  end
78
82
 
79
83
  def indexes(table_name)
80
- super.select { |index| index.columns.kind_of?(Array) }
84
+ super.select { |index| index.columns.is_a?(Array) }
81
85
  end
82
86
  end
83
87
  end
@@ -1,16 +1,19 @@
1
- require "active_record_doctor/tasks/base"
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
2
4
 
3
5
  module ActiveRecordDoctor
4
- module Tasks
6
+ module Detectors
7
+ # Find instances of boolean column presence validations that use presence/absence instead of includes/excludes.
5
8
  class IncorrectBooleanPresenceValidation < Base
6
- @description = 'Detect boolean columns with presence/absence instead of includes/excludes validators'
9
+ @description = "Detect boolean columns with presence/absence instead of includes/excludes validators"
7
10
 
8
11
  def run
9
12
  eager_load!
10
13
 
11
- success(hash_from_pairs(models.reject do |model|
14
+ problems(hash_from_pairs(models.reject do |model|
12
15
  model.table_name.nil? ||
13
- model.table_name == 'schema_migrations' ||
16
+ model.table_name == "schema_migrations" ||
14
17
  !table_exists?(model.table_name)
15
18
  end.map do |model|
16
19
  [
@@ -20,7 +23,7 @@ module ActiveRecordDoctor
20
23
  has_presence_validator?(model, column)
21
24
  end.map(&:name)
22
25
  ]
23
- end.reject do |model_name, columns|
26
+ end.reject do |_model_name, columns|
24
27
  columns.empty?
25
28
  end))
26
29
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ # Find has_many/has_one associations with dependent options not taking the
8
+ # related model's callbacks into account.
9
+ class IncorrectDependentOption < Base
10
+ # rubocop:disable Layout/LineLength
11
+ @description = "Detect associations that should use a different dependent option based on callbacks on the related model"
12
+ # rubocop:enable Layout/LineLength
13
+
14
+ def run
15
+ eager_load!
16
+
17
+ problems(hash_from_pairs(models.reject do |model|
18
+ model.table_name.nil?
19
+ end.map do |model|
20
+ [
21
+ model.name,
22
+ associations_with_incorrect_dependent_options(model)
23
+ ]
24
+ end.reject do |_model_name, associations|
25
+ associations.empty?
26
+ end))
27
+ end
28
+
29
+ private
30
+
31
+ def associations_with_incorrect_dependent_options(model)
32
+ reflections = model.reflect_on_all_associations(:has_many) + model.reflect_on_all_associations(:has_one)
33
+ reflections.map do |reflection|
34
+ if callback_action(reflection) == :invoke && !defines_destroy_callbacks?(reflection.klass)
35
+ suggestion =
36
+ case reflection.macro
37
+ when :has_many then :suggest_delete_all
38
+ when :has_one then :suggest_delete
39
+ else raise("unsupported association type #{reflection.macro}")
40
+ end
41
+
42
+ [reflection.name, suggestion]
43
+ elsif callback_action(reflection) == :skip && defines_destroy_callbacks?(reflection.klass)
44
+ [reflection.name, :suggest_destroy]
45
+ end
46
+ end.compact
47
+ end
48
+
49
+ def callback_action(reflection)
50
+ case reflection.options[:dependent]
51
+ when :delete_all then :skip
52
+ when :destroy then :invoke
53
+ end
54
+ end
55
+
56
+ def defines_destroy_callbacks?(model)
57
+ # Destroying an associated model involves loading it first hence
58
+ # initialize and find are present. If they are defined on the model
59
+ # being deleted then theoretically we can't use :delete_all. It's a bit
60
+ # of an edge case as they usually are either absent or have no side
61
+ # effects but we're being pedantic -- they could be used for audit
62
+ # trial, for instance, and we don't want to skip that.
63
+ model._initialize_callbacks.present? ||
64
+ model._find_callbacks.present? ||
65
+ model._destroy_callbacks.present? ||
66
+ model._commit_callbacks.present? ||
67
+ model._rollback_callbacks.present?
68
+ end
69
+ end
70
+ end
71
+ end
@@ -1,13 +1,16 @@
1
- require "active_record_doctor/tasks/base"
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
2
4
 
3
5
  module ActiveRecordDoctor
4
- module Tasks
6
+ module Detectors
7
+ # Find foreign-key like columns lacking an actual foreign key constraint.
5
8
  class MissingForeignKeys < Base
6
- @description = 'Detect association columns without a foreign key constraint'
9
+ @description = "Detect association columns without a foreign key constraint"
7
10
 
8
11
  def run
9
- success(hash_from_pairs(tables.select do |table|
10
- "schema_migrations" != table
12
+ problems(hash_from_pairs(tables.reject do |table|
13
+ table == "schema_migrations"
11
14
  end.map do |table|
12
15
  [
13
16
  table,
@@ -15,19 +18,19 @@ module ActiveRecordDoctor
15
18
  # We need to skip polymorphic associations as they can reference
16
19
  # multiple tables but a foreign key constraint can reference
17
20
  # a single predefined table.
18
- id?(table, column) &&
21
+ named_like_foreign_key?(column) &&
19
22
  !foreign_key?(table, column) &&
20
23
  !polymorphic_foreign_key?(table, column)
21
24
  end.map(&:name)
22
25
  ]
23
- end.select do |table, columns|
24
- !columns.empty?
26
+ end.reject do |_table, columns|
27
+ columns.empty?
25
28
  end))
26
29
  end
27
30
 
28
31
  private
29
32
 
30
- def id?(table, column)
33
+ def named_like_foreign_key?(column)
31
34
  column.name.end_with?("_id")
32
35
  end
33
36
 
@@ -38,7 +41,7 @@ module ActiveRecordDoctor
38
41
  end
39
42
 
40
43
  def polymorphic_foreign_key?(table, column)
41
- type_column_name = column.name.sub(/_id\Z/, '_type')
44
+ type_column_name = column.name.sub(/_id\Z/, "_type")
42
45
  connection.columns(table).any? do |another_column|
43
46
  another_column.name == type_column_name
44
47
  end
@@ -1,16 +1,20 @@
1
- require "active_record_doctor/tasks/base"
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
2
4
 
3
5
  module ActiveRecordDoctor
4
- module Tasks
6
+ module Detectors
7
+ # Detect model-level presence validators on columns that lack a non-NULL constraint thus allowing potentially
8
+ # invalid insertions.
5
9
  class MissingNonNullConstraint < Base
6
- @description = 'Detect presence validators not backed by a non-NULL constraint'
10
+ @description = "Detect presence validators not backed by a non-NULL constraint"
7
11
 
8
12
  def run
9
13
  eager_load!
10
14
 
11
- success(hash_from_pairs(models.reject do |model|
15
+ problems(hash_from_pairs(models.reject do |model|
12
16
  model.table_name.nil? ||
13
- model.table_name == 'schema_migrations' ||
17
+ model.table_name == "schema_migrations" ||
14
18
  !table_exists?(model.table_name)
15
19
  end.map do |model|
16
20
  [
@@ -21,7 +25,7 @@ module ActiveRecordDoctor
21
25
  column.null
22
26
  end.map(&:name)
23
27
  ]
24
- end.reject do |model_name, columns|
28
+ end.reject do |_model_name, columns|
25
29
  columns.empty?
26
30
  end))
27
31
  end
@@ -29,7 +33,7 @@ module ActiveRecordDoctor
29
33
  private
30
34
 
31
35
  def validator_needed?(model, column)
32
- ![model.primary_key, 'created_at', 'updated_at'].include?(column.name)
36
+ ![model.primary_key, "created_at", "updated_at"].include?(column.name)
33
37
  end
34
38
 
35
39
  def has_mandatory_presence_validator?(model, column)
@@ -1,16 +1,19 @@
1
- require "active_record_doctor/tasks/base"
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
2
4
 
3
5
  module ActiveRecordDoctor
4
- module Tasks
6
+ module Detectors
7
+ # Detect models with non-NULL columns that lack the corresponding model-level validator.
5
8
  class MissingPresenceValidation < Base
6
- @description = 'Detect non-NULL columns without a presence validator'
9
+ @description = "Detect non-NULL columns without a presence validator"
7
10
 
8
11
  def run
9
12
  eager_load!
10
13
 
11
- success(hash_from_pairs(models.reject do |model|
14
+ problems(hash_from_pairs(models.reject do |model|
12
15
  model.table_name.nil? ||
13
- model.table_name == 'schema_migrations' ||
16
+ model.table_name == "schema_migrations" ||
14
17
  !table_exists?(model.table_name)
15
18
  end.map do |model|
16
19
  [
@@ -20,7 +23,7 @@ module ActiveRecordDoctor
20
23
  !validator_present?(model, column)
21
24
  end.map(&:name)
22
25
  ]
23
- end.reject do |model_name, columns|
26
+ end.reject do |_model_name, columns|
24
27
  columns.empty?
25
28
  end))
26
29
  end
@@ -28,7 +31,7 @@ module ActiveRecordDoctor
28
31
  private
29
32
 
30
33
  def validator_needed?(model, column)
31
- ![model.primary_key, 'created_at', 'updated_at'].include?(column.name) &&
34
+ ![model.primary_key, "created_at", "updated_at"].include?(column.name) &&
32
35
  !column.null
33
36
  end
34
37
 
@@ -53,7 +56,7 @@ module ActiveRecordDoctor
53
56
  model.validators.any? do |validator|
54
57
  validator.is_a?(ActiveModel::Validations::ExclusionValidator) &&
55
58
  validator.attributes.include?(column.name.to_sym) &&
56
- validator.options.fetch(:in, []).include?(nil)
59
+ validator.options.fetch(:in, []).include?(nil)
57
60
  end
58
61
  end
59
62