active_record_doctor 1.5.0 → 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 (99) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +152 -12
  3. data/lib/active_record_doctor.rb +20 -2
  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 +12 -29
  7. data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +40 -0
  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 +17 -35
  10. data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +60 -0
  11. data/lib/active_record_doctor/detectors/missing_presence_validation.rb +78 -0
  12. data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +61 -0
  13. data/lib/active_record_doctor/detectors/undefined_table_references.rb +34 -0
  14. data/lib/active_record_doctor/detectors/unindexed_deleted_at.rb +29 -0
  15. data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +48 -0
  16. data/lib/active_record_doctor/printers.rb +3 -1
  17. data/lib/active_record_doctor/printers/io_printer.rb +101 -26
  18. data/lib/active_record_doctor/railtie.rb +3 -1
  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 +33 -0
  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 +23 -10
  34. data/test/model_factory.rb +78 -0
  35. data/test/setup.rb +126 -0
  36. metadata +93 -149
  37. data/Rakefile +0 -28
  38. data/lib/active_record_doctor/compatibility.rb +0 -11
  39. data/lib/active_record_doctor/tasks.rb +0 -4
  40. data/lib/active_record_doctor/tasks/undefined_table_references.rb +0 -34
  41. data/lib/active_record_doctor/tasks/unindexed_deleted_at.rb +0 -40
  42. data/lib/active_record_doctor/tasks/unindexed_foreign_keys.rb +0 -66
  43. data/lib/tasks/active_record_doctor_tasks.rake +0 -27
  44. data/test/active_record_doctor/tasks/extraneous_indexes_test.rb +0 -27
  45. data/test/active_record_doctor/tasks/missing_foreign_keys_test.rb +0 -19
  46. data/test/active_record_doctor/tasks/undefined_table_references_test.rb +0 -19
  47. data/test/active_record_doctor/tasks/unindexed_deleted_at_test.rb +0 -19
  48. data/test/active_record_doctor/tasks/unindexed_foreign_keys_test.rb +0 -19
  49. data/test/dummy/README.rdoc +0 -28
  50. data/test/dummy/Rakefile +0 -6
  51. data/test/dummy/app/assets/javascripts/application.js +0 -13
  52. data/test/dummy/app/assets/stylesheets/application.css +0 -15
  53. data/test/dummy/app/controllers/application_controller.rb +0 -5
  54. data/test/dummy/app/helpers/application_helper.rb +0 -2
  55. data/test/dummy/app/models/application_record.rb +0 -3
  56. data/test/dummy/app/models/comment.rb +0 -3
  57. data/test/dummy/app/models/contract.rb +0 -3
  58. data/test/dummy/app/models/employer.rb +0 -2
  59. data/test/dummy/app/models/profile.rb +0 -2
  60. data/test/dummy/app/models/user.rb +0 -3
  61. data/test/dummy/app/views/layouts/application.html.erb +0 -14
  62. data/test/dummy/bin/bundle +0 -3
  63. data/test/dummy/bin/rails +0 -4
  64. data/test/dummy/bin/rake +0 -4
  65. data/test/dummy/bin/setup +0 -29
  66. data/test/dummy/config.ru +0 -4
  67. data/test/dummy/config/application.rb +0 -23
  68. data/test/dummy/config/boot.rb +0 -5
  69. data/test/dummy/config/database.yml +0 -19
  70. data/test/dummy/config/database.yml.travis +0 -5
  71. data/test/dummy/config/environment.rb +0 -5
  72. data/test/dummy/config/environments/development.rb +0 -41
  73. data/test/dummy/config/environments/production.rb +0 -79
  74. data/test/dummy/config/environments/test.rb +0 -47
  75. data/test/dummy/config/initializers/assets.rb +0 -11
  76. data/test/dummy/config/initializers/backtrace_silencers.rb +0 -7
  77. data/test/dummy/config/initializers/cookies_serializer.rb +0 -3
  78. data/test/dummy/config/initializers/filter_parameter_logging.rb +0 -4
  79. data/test/dummy/config/initializers/inflections.rb +0 -16
  80. data/test/dummy/config/initializers/mime_types.rb +0 -4
  81. data/test/dummy/config/initializers/session_store.rb +0 -3
  82. data/test/dummy/config/initializers/wrap_parameters.rb +0 -14
  83. data/test/dummy/config/locales/en.yml +0 -23
  84. data/test/dummy/config/routes.rb +0 -56
  85. data/test/dummy/config/secrets.yml +0 -22
  86. data/test/dummy/db/migrate/20160213101213_create_employers.rb +0 -15
  87. data/test/dummy/db/migrate/20160213101221_create_users.rb +0 -23
  88. data/test/dummy/db/migrate/20160213101232_create_profiles.rb +0 -15
  89. data/test/dummy/db/migrate/20160604081452_create_comments.rb +0 -11
  90. data/test/dummy/db/migrate/base_migration.rb +0 -5
  91. data/test/dummy/db/schema.rb +0 -68
  92. data/test/dummy/log/development.log +0 -532
  93. data/test/dummy/log/test.log +0 -2699
  94. data/test/dummy/public/404.html +0 -67
  95. data/test/dummy/public/422.html +0 -67
  96. data/test/dummy/public/500.html +0 -66
  97. data/test/dummy/public/favicon.ico +0 -0
  98. data/test/support/spy_printer.rb +0 -52
  99. data/test/test_helper.rb +0 -20
@@ -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,28 +1,16 @@
1
- require "active_record_doctor/compatibility"
2
- require "active_record_doctor/printers/io_printer"
1
+ # frozen_string_literal: true
3
2
 
4
- module ActiveRecordDoctor
5
- module Tasks
6
- class MissingForeignKeys
7
- include Compatibility
8
-
9
- def self.run
10
- new.run
11
- end
3
+ require "active_record_doctor/detectors/base"
12
4
 
13
- def initialize(printer: ActiveRecordDoctor::Printers::IOPrinter.new)
14
- @printer = printer
15
- end
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ # Find foreign-key like columns lacking an actual foreign key constraint.
8
+ class MissingForeignKeys < Base
9
+ @description = "Detect association columns without a foreign key constraint"
16
10
 
17
11
  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|
25
- "schema_migrations" != table
12
+ problems(hash_from_pairs(tables.reject do |table|
13
+ table == "schema_migrations"
26
14
  end.map do |table|
27
15
  [
28
16
  table,
@@ -30,17 +18,19 @@ module ActiveRecordDoctor
30
18
  # We need to skip polymorphic associations as they can reference
31
19
  # multiple tables but a foreign key constraint can reference
32
20
  # a single predefined table.
33
- id?(table, column) &&
21
+ named_like_foreign_key?(column) &&
34
22
  !foreign_key?(table, column) &&
35
23
  !polymorphic_foreign_key?(table, column)
36
24
  end.map(&:name)
37
25
  ]
38
- end.select do |table, columns|
39
- !columns.empty?
40
- end)
26
+ end.reject do |_table, columns|
27
+ columns.empty?
28
+ end))
41
29
  end
42
30
 
43
- def id?(table, column)
31
+ private
32
+
33
+ def named_like_foreign_key?(column)
44
34
  column.name.end_with?("_id")
45
35
  end
46
36
 
@@ -51,19 +41,11 @@ module ActiveRecordDoctor
51
41
  end
52
42
 
53
43
  def polymorphic_foreign_key?(table, column)
54
- type_column_name = column.name.sub(/_id\Z/, '_type')
44
+ type_column_name = column.name.sub(/_id\Z/, "_type")
55
45
  connection.columns(table).any? do |another_column|
56
46
  another_column.name == type_column_name
57
47
  end
58
48
  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
49
  end
68
50
  end
69
51
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ # Detect model-level presence validators on columns that lack a non-NULL constraint thus allowing potentially
8
+ # invalid insertions.
9
+ class MissingNonNullConstraint < Base
10
+ @description = "Detect presence validators not backed by a non-NULL constraint"
11
+
12
+ def run
13
+ eager_load!
14
+
15
+ problems(hash_from_pairs(models.reject do |model|
16
+ model.table_name.nil? ||
17
+ model.table_name == "schema_migrations" ||
18
+ !table_exists?(model.table_name)
19
+ end.map do |model|
20
+ [
21
+ model.table_name,
22
+ connection.columns(model.table_name).select do |column|
23
+ validator_needed?(model, column) &&
24
+ has_mandatory_presence_validator?(model, column) &&
25
+ column.null
26
+ end.map(&:name)
27
+ ]
28
+ end.reject do |_model_name, columns|
29
+ columns.empty?
30
+ end))
31
+ end
32
+
33
+ private
34
+
35
+ def validator_needed?(model, column)
36
+ ![model.primary_key, "created_at", "updated_at"].include?(column.name)
37
+ end
38
+
39
+ def has_mandatory_presence_validator?(model, column)
40
+ # A foreign key can be validates via the column name (e.g. company_id)
41
+ # or the association name (e.g. company). We collect the allowed names
42
+ # in an array to check for their presence in the validator definition
43
+ # in one go.
44
+ attribute_name_forms = [column.name.to_sym]
45
+ belongs_to = model.reflect_on_all_associations(:belongs_to).find do |reflection|
46
+ reflection.foreign_key == column.name
47
+ end
48
+ attribute_name_forms << belongs_to.name.to_sym if belongs_to
49
+
50
+ model.validators.any? do |validator|
51
+ validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
52
+ (validator.attributes & attribute_name_forms).present? &&
53
+ !validator.options[:allow_nil] &&
54
+ !validator.options[:if] &&
55
+ !validator.options[:unless]
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ # Detect models with non-NULL columns that lack the corresponding model-level validator.
8
+ class MissingPresenceValidation < Base
9
+ @description = "Detect non-NULL columns without a presence validator"
10
+
11
+ def run
12
+ eager_load!
13
+
14
+ problems(hash_from_pairs(models.reject do |model|
15
+ model.table_name.nil? ||
16
+ model.table_name == "schema_migrations" ||
17
+ !table_exists?(model.table_name)
18
+ end.map do |model|
19
+ [
20
+ model.name,
21
+ connection.columns(model.table_name).select do |column|
22
+ validator_needed?(model, column) &&
23
+ !validator_present?(model, column)
24
+ end.map(&:name)
25
+ ]
26
+ end.reject do |_model_name, columns|
27
+ columns.empty?
28
+ end))
29
+ end
30
+
31
+ private
32
+
33
+ def validator_needed?(model, column)
34
+ ![model.primary_key, "created_at", "updated_at"].include?(column.name) &&
35
+ !column.null
36
+ end
37
+
38
+ def validator_present?(model, column)
39
+ if column.type == :boolean
40
+ inclusion_validator_present?(model, column) ||
41
+ exclusion_validator_present?(model, column)
42
+ else
43
+ presence_validator_present?(model, column)
44
+ end
45
+ end
46
+
47
+ def inclusion_validator_present?(model, column)
48
+ model.validators.any? do |validator|
49
+ validator.is_a?(ActiveModel::Validations::InclusionValidator) &&
50
+ validator.attributes.include?(column.name.to_sym) &&
51
+ !validator.options.fetch(:in, []).include?(nil)
52
+ end
53
+ end
54
+
55
+ def exclusion_validator_present?(model, column)
56
+ model.validators.any? do |validator|
57
+ validator.is_a?(ActiveModel::Validations::ExclusionValidator) &&
58
+ validator.attributes.include?(column.name.to_sym) &&
59
+ validator.options.fetch(:in, []).include?(nil)
60
+ end
61
+ end
62
+
63
+ def presence_validator_present?(model, column)
64
+ allowed_attributes = [column.name.to_sym]
65
+
66
+ belongs_to = model.reflect_on_all_associations(:belongs_to).find do |reflection|
67
+ reflection.foreign_key == column.name
68
+ end
69
+ allowed_attributes << belongs_to.name.to_sym if belongs_to
70
+
71
+ model.validators.any? do |validator|
72
+ validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
73
+ (validator.attributes & allowed_attributes).present?
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ # Detect columns covered by a uniqueness validation that lack the corresponding unique index thus risking duplicate
8
+ # inserts.
9
+ class MissingUniqueIndexes < Base
10
+ @description = "Detect columns covered by a uniqueness validator without a unique index"
11
+
12
+ def run
13
+ eager_load!
14
+
15
+ problems(hash_from_pairs(models.reject do |model|
16
+ model.table_name.nil?
17
+ end.map do |model|
18
+ [
19
+ model.table_name,
20
+ model.validators.select do |validator|
21
+ table_name = model.table_name
22
+ scope = validator.options.fetch(:scope, [])
23
+
24
+ validator.is_a?(ActiveRecord::Validations::UniquenessValidator) &&
25
+ supported_validator?(validator) &&
26
+ !unique_index?(table_name, validator.attributes, scope)
27
+ end.map do |validator|
28
+ scope = Array(validator.options.fetch(:scope, []))
29
+ attributes = validator.attributes
30
+ (scope + attributes).map(&:to_s)
31
+ end
32
+ ]
33
+ end.reject do |_table_name, indexes|
34
+ indexes.empty?
35
+ end))
36
+ end
37
+
38
+ private
39
+
40
+ def supported_validator?(validator)
41
+ validator.options[:if].nil? &&
42
+ validator.options[:unless].nil? &&
43
+ validator.options[:conditions].nil? &&
44
+
45
+ # In Rails 6, default option values are no longer explicitly set on
46
+ # options so if the key is absent we must fetch the default value
47
+ # ourselves. case_sensitive is the default in 4.2+ so it's safe to
48
+ # put true literally.
49
+ validator.options.fetch(:case_sensitive, true)
50
+ end
51
+
52
+ def unique_index?(table_name, columns, scope)
53
+ columns = (Array(scope) + columns).map(&:to_s)
54
+
55
+ indexes(table_name).any? do |index|
56
+ index.columns.to_set == columns.to_set && index.unique
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ # Find models referencing non-existent database tables or views.
8
+ class UndefinedTableReferences < Base
9
+ @description = "Detect models referencing undefined tables or views"
10
+
11
+ def run
12
+ eager_load!
13
+
14
+ # If we can't list views due to old Rails version or unsupported
15
+ # database then existing_views is nil. We inform the caller we haven't
16
+ # consulted views so that it can display an appropriate warning.
17
+ existing_views = views
18
+
19
+ offending_models = models.select do |model|
20
+ model.table_name.present? &&
21
+ !tables.include?(model.table_name) &&
22
+ (
23
+ existing_views.nil? ||
24
+ !existing_views.include?(model.table_name)
25
+ )
26
+ end.map do |model|
27
+ [model.name, model.table_name]
28
+ end
29
+
30
+ problems(offending_models, views_checked: !existing_views.nil?)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ # Find unindexed deleted_at columns.
8
+ class UnindexedDeletedAt < Base
9
+ PATTERN = [
10
+ "deleted_at",
11
+ "discarded_at"
12
+ ].join("|").freeze
13
+
14
+ @description = "Detect unindexed deleted_at columns"
15
+
16
+ def run
17
+ problems(connection.tables.select do |table|
18
+ connection.columns(table).any? { |column| column.name =~ /^#{PATTERN}$/ }
19
+ end.flat_map do |table|
20
+ connection.indexes(table).reject do |index|
21
+ index.where =~ /\b#{PATTERN}\s+IS\s+NULL\b/i
22
+ end.map do |index|
23
+ index.name
24
+ end
25
+ end)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ # Find foreign keys that lack indexes (usually recommended for performance reasons).
8
+ class UnindexedForeignKeys < Base
9
+ @description = "Detect foreign keys without an index on them"
10
+
11
+ def run
12
+ problems(hash_from_pairs(tables.reject do |table|
13
+ table == "schema_migrations"
14
+ end.map do |table|
15
+ [
16
+ table,
17
+ connection.columns(table).select do |column|
18
+ foreign_key?(column) &&
19
+ !indexed?(table, column) &&
20
+ !indexed_as_polymorphic?(table, column)
21
+ end.map(&:name)
22
+ ]
23
+ end.reject do |_table, columns|
24
+ columns.empty?
25
+ end))
26
+ end
27
+
28
+ private
29
+
30
+ def foreign_key?(column)
31
+ column.name.end_with?("_id")
32
+ end
33
+
34
+ def indexed?(table, column)
35
+ connection.indexes(table).any? do |index|
36
+ index.columns.first == column.name
37
+ end
38
+ end
39
+
40
+ def indexed_as_polymorphic?(table, column)
41
+ type_column_name = column.name.sub(/_id\Z/, "_type")
42
+ connection.indexes(table).any? do |index|
43
+ index.columns == [type_column_name, column.name]
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end