active_record_doctor 1.7.2 → 1.8.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 (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