active_record_doctor 1.7.1 → 1.9.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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +287 -43
  3. data/lib/active_record_doctor/config/default.rb +59 -0
  4. data/lib/active_record_doctor/config/loader.rb +137 -0
  5. data/lib/active_record_doctor/config.rb +14 -0
  6. data/lib/active_record_doctor/detectors/base.rb +155 -0
  7. data/lib/active_record_doctor/detectors/extraneous_indexes.rb +114 -0
  8. data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +49 -0
  9. data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +107 -0
  10. data/lib/active_record_doctor/detectors/mismatched_foreign_key_type.rb +45 -0
  11. data/lib/active_record_doctor/detectors/missing_foreign_keys.rb +60 -0
  12. data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +72 -0
  13. data/lib/active_record_doctor/{tasks → detectors}/missing_presence_validation.rb +35 -21
  14. data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +71 -0
  15. data/lib/active_record_doctor/detectors/short_primary_key_type.rb +41 -0
  16. data/lib/active_record_doctor/detectors/undefined_table_references.rb +33 -0
  17. data/lib/active_record_doctor/detectors/unindexed_deleted_at.rb +55 -0
  18. data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +59 -0
  19. data/lib/active_record_doctor/detectors.rb +21 -0
  20. data/lib/active_record_doctor/errors.rb +226 -0
  21. data/lib/active_record_doctor/help.rb +39 -0
  22. data/lib/active_record_doctor/printers.rb +3 -1
  23. data/lib/active_record_doctor/railtie.rb +2 -0
  24. data/lib/active_record_doctor/rake/task.rb +78 -0
  25. data/lib/active_record_doctor/runner.rb +41 -0
  26. data/lib/active_record_doctor/version.rb +3 -1
  27. data/lib/active_record_doctor.rb +24 -2
  28. data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +46 -29
  29. data/lib/tasks/active_record_doctor.rake +21 -29
  30. data/test/active_record_doctor/config/loader_test.rb +120 -0
  31. data/test/active_record_doctor/config_test.rb +116 -0
  32. data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +190 -0
  33. data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +79 -0
  34. data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +295 -0
  35. data/test/active_record_doctor/detectors/mismatched_foreign_key_type_test.rb +82 -0
  36. data/test/active_record_doctor/detectors/missing_foreign_keys_test.rb +70 -0
  37. data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +216 -0
  38. data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +168 -0
  39. data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +163 -0
  40. data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +64 -0
  41. data/test/active_record_doctor/detectors/undefined_table_references_test.rb +57 -0
  42. data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +171 -0
  43. data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +78 -0
  44. data/test/active_record_doctor/runner_test.rb +42 -0
  45. data/test/generators/active_record_doctor/add_indexes/add_indexes_generator_test.rb +131 -0
  46. data/test/model_factory.rb +128 -0
  47. data/test/setup.rb +116 -0
  48. metadata +103 -154
  49. data/Rakefile +0 -28
  50. data/lib/active_record_doctor/printers/io_printer.rb +0 -105
  51. data/lib/active_record_doctor/tasks/base.rb +0 -78
  52. data/lib/active_record_doctor/tasks/extraneous_indexes.rb +0 -82
  53. data/lib/active_record_doctor/tasks/incorrect_boolean_presence_validation.rb +0 -33
  54. data/lib/active_record_doctor/tasks/missing_foreign_keys.rb +0 -46
  55. data/lib/active_record_doctor/tasks/missing_non_null_constraint.rb +0 -52
  56. data/lib/active_record_doctor/tasks/missing_unique_indexes.rb +0 -56
  57. data/lib/active_record_doctor/tasks/undefined_table_references.rb +0 -33
  58. data/lib/active_record_doctor/tasks/unindexed_deleted_at.rb +0 -19
  59. data/lib/active_record_doctor/tasks/unindexed_foreign_keys.rb +0 -43
  60. data/lib/active_record_doctor/tasks.rb +0 -7
  61. data/test/active_record_doctor/printers/io_printer_test.rb +0 -20
  62. data/test/active_record_doctor/tasks/extraneous_indexes_test.rb +0 -81
  63. data/test/active_record_doctor/tasks/incorrect_boolean_presence_validation_test.rb +0 -33
  64. data/test/active_record_doctor/tasks/missing_foreign_keys_test.rb +0 -27
  65. data/test/active_record_doctor/tasks/missing_non_null_constraint_test.rb +0 -108
  66. data/test/active_record_doctor/tasks/missing_presence_validation_test.rb +0 -110
  67. data/test/active_record_doctor/tasks/missing_unique_indexes_test.rb +0 -95
  68. data/test/active_record_doctor/tasks/undefined_table_references_test.rb +0 -51
  69. data/test/active_record_doctor/tasks/unindexed_deleted_at_test.rb +0 -34
  70. data/test/active_record_doctor/tasks/unindexed_foreign_keys_test.rb +0 -27
  71. data/test/dummy/README.rdoc +0 -28
  72. data/test/dummy/Rakefile +0 -6
  73. data/test/dummy/app/assets/config/manifest.js +0 -1
  74. data/test/dummy/app/assets/javascripts/application.js +0 -13
  75. data/test/dummy/app/assets/stylesheets/application.css +0 -15
  76. data/test/dummy/app/controllers/application_controller.rb +0 -5
  77. data/test/dummy/app/helpers/application_helper.rb +0 -2
  78. data/test/dummy/app/models/application_record.rb +0 -3
  79. data/test/dummy/app/views/layouts/application.html.erb +0 -14
  80. data/test/dummy/bin/bundle +0 -3
  81. data/test/dummy/bin/rails +0 -4
  82. data/test/dummy/bin/rake +0 -4
  83. data/test/dummy/bin/setup +0 -29
  84. data/test/dummy/config/application.rb +0 -23
  85. data/test/dummy/config/boot.rb +0 -5
  86. data/test/dummy/config/database.yml +0 -19
  87. data/test/dummy/config/database.yml.travis +0 -5
  88. data/test/dummy/config/environment.rb +0 -5
  89. data/test/dummy/config/environments/development.rb +0 -41
  90. data/test/dummy/config/environments/production.rb +0 -79
  91. data/test/dummy/config/environments/test.rb +0 -47
  92. data/test/dummy/config/initializers/assets.rb +0 -11
  93. data/test/dummy/config/initializers/backtrace_silencers.rb +0 -7
  94. data/test/dummy/config/initializers/cookies_serializer.rb +0 -3
  95. data/test/dummy/config/initializers/filter_parameter_logging.rb +0 -4
  96. data/test/dummy/config/initializers/inflections.rb +0 -16
  97. data/test/dummy/config/initializers/mime_types.rb +0 -4
  98. data/test/dummy/config/initializers/session_store.rb +0 -3
  99. data/test/dummy/config/initializers/wrap_parameters.rb +0 -14
  100. data/test/dummy/config/locales/en.yml +0 -23
  101. data/test/dummy/config/routes.rb +0 -56
  102. data/test/dummy/config/secrets.yml +0 -22
  103. data/test/dummy/config.ru +0 -4
  104. data/test/dummy/db/migrate/base_migration.rb +0 -5
  105. data/test/dummy/db/schema.rb +0 -19
  106. data/test/dummy/log/development.log +0 -111
  107. data/test/dummy/log/test.log +0 -79424
  108. data/test/dummy/public/404.html +0 -67
  109. data/test/dummy/public/422.html +0 -67
  110. data/test/dummy/public/500.html +0 -66
  111. data/test/dummy/public/favicon.ico +0 -0
  112. data/test/support/assertions.rb +0 -11
  113. data/test/support/temping.rb +0 -25
  114. data/test/test_helper.rb +0 -17
@@ -0,0 +1,155 @@
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, :config
9
+
10
+ def run(config, io)
11
+ new(config, io).run
12
+ end
13
+
14
+ def underscored_name
15
+ name.demodulize.underscore.to_sym
16
+ end
17
+
18
+ def locals_and_globals
19
+ locals = []
20
+ globals = []
21
+
22
+ config.each do |key, metadata|
23
+ locals << key
24
+ globals << key if metadata[:global]
25
+ end
26
+
27
+ [locals, globals]
28
+ end
29
+ end
30
+
31
+ def initialize(config, io)
32
+ @problems = []
33
+ @config = config
34
+ @io = io
35
+ end
36
+
37
+ def run
38
+ @problems = []
39
+
40
+ detect
41
+ @problems.each do |problem|
42
+ @io.puts(message(**problem))
43
+ end
44
+
45
+ success = @problems.empty?
46
+ @problems = nil
47
+ success
48
+ end
49
+
50
+ private
51
+
52
+ def config(key)
53
+ local = @config.detectors.fetch(underscored_name).fetch(key)
54
+ return local if !self.class.config.fetch(key)[:global]
55
+
56
+ global = @config.globals[key]
57
+ return local if global.nil?
58
+
59
+ # Right now, all globals are arrays so we can merge them here. Once
60
+ # we add non-array globals we'll need to support per-global merging.
61
+ Array.new(local).concat(global)
62
+ end
63
+
64
+ def detect
65
+ raise("#detect should be implemented by a subclass")
66
+ end
67
+
68
+ def message(**_attrs)
69
+ raise("#message should be implemented by a subclass")
70
+ end
71
+
72
+ def problem!(**attrs)
73
+ @problems << attrs
74
+ end
75
+
76
+ def warning(message)
77
+ puts(message)
78
+ end
79
+
80
+ def connection
81
+ @connection ||= ActiveRecord::Base.connection
82
+ end
83
+
84
+ def indexes(table_name, except: [])
85
+ connection.indexes(table_name).reject do |index|
86
+ except.include?(index.name)
87
+ end
88
+ end
89
+
90
+ def tables(except: [])
91
+ tables =
92
+ if ActiveRecord::VERSION::STRING >= "5.1"
93
+ connection.tables
94
+ else
95
+ connection.data_sources
96
+ end
97
+
98
+ tables.reject do |table|
99
+ except.include?(table)
100
+ end
101
+ end
102
+
103
+ def table_exists?(table_name)
104
+ if ActiveRecord::VERSION::STRING >= "5.1"
105
+ connection.table_exists?(table_name)
106
+ else
107
+ connection.data_source_exists?(table_name)
108
+ end
109
+ end
110
+
111
+ def tables_and_views
112
+ if connection.respond_to?(:data_sources)
113
+ connection.data_sources
114
+ else
115
+ connection.tables
116
+ end
117
+ end
118
+
119
+ def primary_key(table_name)
120
+ primary_key_name = connection.primary_key(table_name)
121
+ return nil if primary_key_name.nil?
122
+
123
+ column(table_name, primary_key_name)
124
+ end
125
+
126
+ def column(table_name, column_name)
127
+ connection.columns(table_name).find { |column| column.name == column_name }
128
+ end
129
+
130
+ def views
131
+ @views ||=
132
+ if connection.respond_to?(:views)
133
+ connection.views
134
+ elsif connection.adapter_name == "PostgreSQL"
135
+ ActiveRecord::Base.connection.execute(<<-SQL).map { |tuple| tuple.fetch("relname") }
136
+ SELECT c.relname FROM pg_class c WHERE c.relkind IN ('m', 'v')
137
+ SQL
138
+ else
139
+ # We don't support this Rails/database combination yet.
140
+ nil
141
+ end
142
+ end
143
+
144
+ def models(except: [])
145
+ ActiveRecord::Base.descendants.reject do |model|
146
+ model.name.start_with?("HABTM_") || except.include?(model.name)
147
+ end
148
+ end
149
+
150
+ def underscored_name
151
+ self.class.underscored_name
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ class ExtraneousIndexes < Base # :nodoc:
8
+ @description = "identify indexes that can be dropped without degrading performance"
9
+ @config = {
10
+ ignore_tables: {
11
+ description: "tables whose indexes should never be reported as extraneous",
12
+ global: true
13
+ },
14
+ ignore_indexes: {
15
+ description: "indexes that should never be reported as extraneous",
16
+ global: true
17
+ }
18
+ }
19
+
20
+ private
21
+
22
+ def message(extraneous_index:, replacement_indexes:)
23
+ if replacement_indexes.nil?
24
+ "remove #{extraneous_index} - coincides with the primary key on the table"
25
+ else
26
+ "remove #{extraneous_index} - can be replaced by #{replacement_indexes.join(' or ')}"
27
+ end
28
+ end
29
+
30
+ def detect
31
+ subindexes_of_multi_column_indexes
32
+ indexed_primary_keys
33
+ end
34
+
35
+ def subindexes_of_multi_column_indexes
36
+ tables(except: config(:ignore_tables)).each do |table|
37
+ indexes = indexes(table)
38
+ maximal_indexes = indexes.select { |index| maximal?(indexes, index) }
39
+
40
+ indexes.each do |index|
41
+ next if maximal_indexes.include?(index)
42
+
43
+ replacement_indexes = maximal_indexes.select do |maximum_index|
44
+ cover?(maximum_index, index)
45
+ end.map(&:name).sort
46
+
47
+ next if config(:ignore_indexes).include?(index.name)
48
+
49
+ problem!(extraneous_index: index.name, replacement_indexes: replacement_indexes)
50
+ end
51
+ end
52
+ end
53
+
54
+ def indexed_primary_keys
55
+ tables(except: config(:ignore_tables)).each do |table|
56
+ indexes(table).each do |index|
57
+ next if config(:ignore_indexes).include?(index.name)
58
+ next if index.columns != ["id"]
59
+
60
+ problem!(extraneous_index: index.name, replacement_indexes: nil)
61
+ end
62
+ end
63
+ end
64
+
65
+ def maximal?(indexes, index)
66
+ indexes.all? do |another_index|
67
+ index == another_index || !cover?(another_index, index)
68
+ end
69
+ end
70
+
71
+ # Does lhs cover rhs?
72
+ def cover?(lhs, rhs)
73
+ return false unless compatible_options?(lhs, rhs)
74
+
75
+ case [lhs.unique, rhs.unique]
76
+ when [true, true]
77
+ lhs.columns == rhs.columns
78
+ when [false, true]
79
+ false
80
+ else
81
+ prefix?(rhs, lhs)
82
+ end
83
+ end
84
+
85
+ def prefix?(lhs, rhs)
86
+ lhs.columns.count <= rhs.columns.count &&
87
+ rhs.columns[0...lhs.columns.count] == lhs.columns
88
+ end
89
+
90
+ def indexes(table_name)
91
+ super.select { |index| index.columns.is_a?(Array) }
92
+ end
93
+
94
+ def compatible_options?(lhs, rhs)
95
+ lhs.type == rhs.type &&
96
+ lhs.using == rhs.using &&
97
+ lhs.where == rhs.where &&
98
+ same_opclasses?(lhs, rhs)
99
+ end
100
+
101
+ def same_opclasses?(lhs, rhs)
102
+ if ActiveRecord::VERSION::STRING >= "5.2"
103
+ rhs.columns.all? do |column|
104
+ lhs_opclass = lhs.opclasses.is_a?(Hash) ? lhs.opclasses[column] : lhs.opclasses
105
+ rhs_opclass = rhs.opclasses.is_a?(Hash) ? rhs.opclasses[column] : rhs.opclasses
106
+ lhs_opclass == rhs_opclass
107
+ end
108
+ else
109
+ true
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ class IncorrectBooleanPresenceValidation < Base # :nodoc:
8
+ @description = "detect persence (instead of inclusion) validators on boolean columns"
9
+ @config = {
10
+ ignore_models: {
11
+ description: "models whose validators should not be checked",
12
+ global: true
13
+ },
14
+ ignore_attributes: {
15
+ description: "attributes, written as Model.attribute, whose validators should not be checked"
16
+ }
17
+ }
18
+
19
+ private
20
+
21
+ def message(model:, attribute:)
22
+ # rubocop:disable Layout/LineLength
23
+ "replace the `presence` validator on #{model}.#{attribute} with `inclusion` - `presence` can't be used on booleans"
24
+ # rubocop:enable Layout/LineLength
25
+ end
26
+
27
+ def detect
28
+ models(except: config(:ignore_models)).each do |model|
29
+ next if model.table_name.nil?
30
+ next unless table_exists?(model.table_name)
31
+
32
+ connection.columns(model.table_name).each do |column|
33
+ next if config(:ignore_attributes).include?("#{model.name}.#{column.name}")
34
+ next unless column.type == :boolean
35
+ next unless has_presence_validator?(model, column)
36
+
37
+ problem!(model: model.name, attribute: column.name)
38
+ end
39
+ end
40
+ end
41
+
42
+ def has_presence_validator?(model, column)
43
+ model.validators.any? do |validator|
44
+ validator.kind == :presence && validator.attributes.include?(column.name.to_sym)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ class IncorrectDependentOption < Base # :nodoc:
8
+ @description = "detect associations with incorrect dependent options"
9
+ @config = {
10
+ ignore_models: {
11
+ description: "models whose associations should not be checked",
12
+ global: true
13
+ },
14
+ ignore_associations: {
15
+ description: "associations, written as Model.association, that should not be checked"
16
+ }
17
+ }
18
+
19
+ private
20
+
21
+ def message(model:, association:, problem:)
22
+ # rubocop:disable Layout/LineLength
23
+ case problem
24
+ when :suggest_destroy
25
+ "use `dependent: :destroy` or similar on #{model}.#{association} - the associated model has callbacks that are currently skipped"
26
+ when :suggest_delete
27
+ "use `dependent: :delete` or similar on #{model}.#{association} - the associated model has no callbacks and can be deleted without loading"
28
+ when :suggest_delete_all
29
+ "use `dependent: :delete_all` or similar on #{model}.#{association} - associated models have no validations and can be deleted in bulk"
30
+ end
31
+ # rubocop:enable Layout/LineLength
32
+ end
33
+
34
+ def detect
35
+ models(except: config(:ignore_models)).each do |model|
36
+ next if model.table_name.nil?
37
+
38
+ associations = model.reflect_on_all_associations(:has_many) +
39
+ model.reflect_on_all_associations(:has_one) +
40
+ model.reflect_on_all_associations(:belongs_to)
41
+
42
+ associations.each do |association|
43
+ next if config(:ignore_associations).include?("#{model.name}.#{association.name}")
44
+
45
+ if callback_action(association) == :invoke && deletable?(association.klass)
46
+ suggestion =
47
+ case association.macro
48
+ when :has_many then :suggest_delete_all
49
+ when :has_one, :belongs_to then :suggest_delete
50
+ else raise("unsupported association type #{association.macro}")
51
+ end
52
+
53
+ problem!(model: model.name, association: association.name, problem: suggestion)
54
+ elsif callback_action(association) == :skip && !deletable?(association.klass)
55
+ problem!(model: model.name, association: association.name, problem: :suggest_destroy)
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ def callback_action(reflection)
62
+ case reflection.options[:dependent]
63
+ when :delete, :delete_all then :skip
64
+ when :destroy then :invoke
65
+ end
66
+ end
67
+
68
+ def deletable?(model)
69
+ !defines_destroy_callbacks?(model) &&
70
+ dependent_models(model).all? do |dependent_model|
71
+ foreign_key = foreign_key(dependent_model.table_name, model.table_name)
72
+
73
+ foreign_key.nil? ||
74
+ foreign_key.on_delete == :nullify || (
75
+ foreign_key.on_delete == :cascade && deletable?(dependent_model)
76
+ )
77
+ end
78
+ end
79
+
80
+ def defines_destroy_callbacks?(model)
81
+ # Destroying an associated model involves loading it first hence
82
+ # initialize and find are present. If they are defined on the model
83
+ # being deleted then theoretically we can't use :delete_all. It's a bit
84
+ # of an edge case as they usually are either absent or have no side
85
+ # effects but we're being pedantic -- they could be used for audit
86
+ # trial, for instance, and we don't want to skip that.
87
+ model._initialize_callbacks.present? ||
88
+ model._find_callbacks.present? ||
89
+ model._destroy_callbacks.present? ||
90
+ model._commit_callbacks.present? ||
91
+ model._rollback_callbacks.present?
92
+ end
93
+
94
+ def dependent_models(model)
95
+ reflections = model.reflect_on_all_associations(:has_many) +
96
+ model.reflect_on_all_associations(:has_one)
97
+ reflections.map(&:klass)
98
+ end
99
+
100
+ def foreign_key(from_table, to_table)
101
+ connection.foreign_keys(from_table).find do |foreign_key|
102
+ foreign_key.to_table == to_table
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ class MismatchedForeignKeyType < Base # :nodoc:
8
+ @description = "detect foreign key type mismatches"
9
+ @config = {
10
+ ignore_tables: {
11
+ description: "tables whose foreign keys should not be checked",
12
+ global: true
13
+ },
14
+ ignore_columns: {
15
+ description: "foreign keys, written as table.column, that should not be checked"
16
+ }
17
+ }
18
+
19
+ private
20
+
21
+ def message(table:, column:)
22
+ # rubocop:disable Layout/LineLength
23
+ "#{table}.#{column} references a column of different type - foreign keys should be of the same type as the referenced column"
24
+ # rubocop:enable Layout/LineLength
25
+ end
26
+
27
+ def detect
28
+ tables(except: config(:ignore_tables)).each do |table|
29
+ connection.foreign_keys(table).each do |foreign_key|
30
+ from_column = column(table, foreign_key.column)
31
+
32
+ next if config(:ignore_columns).include?("#{table}.#{from_column.name}")
33
+
34
+ to_table = foreign_key.to_table
35
+ primary_key = primary_key(to_table)
36
+
37
+ next if from_column.sql_type == primary_key.sql_type
38
+
39
+ problem!(table: table, column: from_column.name)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ 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
+ class MissingForeignKeys < Base # :nodoc:
8
+ @description = "detect foreign-key-like columns lacking an actual foreign key constraint"
9
+ @config = {
10
+ ignore_tables: {
11
+ description: "tables whose columns should not be checked",
12
+ global: true
13
+ },
14
+ ignore_columns: {
15
+ description: "columns, written as table.column, that should not be checked"
16
+ }
17
+ }
18
+
19
+ private
20
+
21
+ def message(table:, column:)
22
+ "create a foreign key on #{table}.#{column} - looks like an association without a foreign key constraint"
23
+ end
24
+
25
+ def detect
26
+ tables(except: config(:ignore_tables)).each do |table|
27
+ connection.columns(table).each do |column|
28
+ next if config(:ignore_columns).include?("#{table}.#{column.name}")
29
+
30
+ # We need to skip polymorphic associations as they can reference
31
+ # multiple tables but a foreign key constraint can reference
32
+ # a single predefined table.
33
+ next unless named_like_foreign_key?(column)
34
+ next if foreign_key?(table, column)
35
+ next if polymorphic_foreign_key?(table, column)
36
+
37
+ problem!(table: table, column: column.name)
38
+ end
39
+ end
40
+ end
41
+
42
+ def named_like_foreign_key?(column)
43
+ column.name.end_with?("_id")
44
+ end
45
+
46
+ def foreign_key?(table, column)
47
+ connection.foreign_keys(table).any? do |foreign_key|
48
+ foreign_key.options[:column] == column.name
49
+ end
50
+ end
51
+
52
+ def polymorphic_foreign_key?(table, column)
53
+ type_column_name = column.name.sub(/_id\Z/, "_type")
54
+ connection.columns(table).any? do |another_column|
55
+ another_column.name == type_column_name
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ class MissingNonNullConstraint < Base # :nodoc:
8
+ @description = "detect columns whose presence is always validated but isn't enforced via a non-NULL constraint"
9
+ @config = {
10
+ ignore_tables: {
11
+ description: "tables whose columns should not be checked",
12
+ global: true
13
+ },
14
+ ignore_columns: {
15
+ description: "columns, written as table.column, that should not be checked"
16
+ }
17
+ }
18
+
19
+ private
20
+
21
+ def message(column:, table:)
22
+ "add `NOT NULL` to #{table}.#{column} - models validates its presence but it's not non-NULL in the database"
23
+ end
24
+
25
+ def detect
26
+ table_models = models.group_by(&:table_name)
27
+ table_models.delete_if { |table| table.nil? || !table_exists?(table) }
28
+
29
+ table_models.each do |table, models|
30
+ next if config(:ignore_tables).include?(table)
31
+
32
+ concrete_models = models.reject do |model|
33
+ model.abstract_class? || sti_base_model?(model)
34
+ end
35
+
36
+ connection.columns(table).each do |column|
37
+ next if config(:ignore_columns).include?("#{table}.#{column.name}")
38
+ next if !column.null
39
+ next if !concrete_models.all? { |model| non_null_needed?(model, column) }
40
+
41
+ problem!(column: column.name, table: table)
42
+ end
43
+ end
44
+ end
45
+
46
+ def sti_base_model?(model)
47
+ model.base_class == model &&
48
+ model.columns_hash.include?(model.inheritance_column.to_s)
49
+ end
50
+
51
+ def non_null_needed?(model, column)
52
+ # A foreign key can be validates via the column name (e.g. company_id)
53
+ # or the association name (e.g. company). We collect the allowed names
54
+ # in an array to check for their presence in the validator definition
55
+ # in one go.
56
+ attribute_name_forms = [column.name.to_sym]
57
+ belongs_to = model.reflect_on_all_associations(:belongs_to).find do |reflection|
58
+ reflection.foreign_key == column.name
59
+ end
60
+ attribute_name_forms << belongs_to.name.to_sym if belongs_to
61
+
62
+ model.validators.any? do |validator|
63
+ validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
64
+ (validator.attributes & attribute_name_forms).present? &&
65
+ !validator.options[:allow_nil] &&
66
+ !validator.options[:if] &&
67
+ !validator.options[:unless]
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,30 +1,44 @@
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
5
- class MissingPresenceValidation < Base
6
- def run
7
- eager_load!
6
+ module Detectors
7
+ class MissingPresenceValidation < Base # :nodoc:
8
+ @description = "detect non-NULL columns without a corresponding presence validator"
9
+ @config = {
10
+ ignore_models: {
11
+ description: "models whose underlying tables' columns should not be checked",
12
+ global: true
13
+ },
14
+ ignore_attributes: {
15
+ description: "specific attributes, written as Model.attribute, that should not be checked"
16
+ }
17
+ }
18
+
19
+ private
8
20
 
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.name,
14
- connection.columns(model.table_name).select do |column|
15
- validator_needed?(model, column) &&
16
- !validator_present?(model, column)
17
- end.map(&:name)
18
- ]
19
- end.reject do |model_name, columns|
20
- columns.empty?
21
- end))
21
+ def message(column:, model:)
22
+ "add a `presence` validator to #{model}.#{column} - it's NOT NULL but lacks a validator"
22
23
  end
23
24
 
24
- private
25
+ def detect
26
+ models(except: config(:ignore_models)).each do |model|
27
+ next if model.table_name.nil?
28
+ next unless table_exists?(model.table_name)
29
+
30
+ connection.columns(model.table_name).each do |column|
31
+ next unless validator_needed?(model, column)
32
+ next if validator_present?(model, column)
33
+ next if config(:ignore_attributes).include?("#{model}.#{column.name}")
34
+
35
+ problem!(column: column.name, model: model.name)
36
+ end
37
+ end
38
+ end
25
39
 
26
40
  def validator_needed?(model, column)
27
- ![model.primary_key, 'created_at', 'updated_at'].include?(column.name) &&
41
+ ![model.primary_key, "created_at", "updated_at"].include?(column.name) &&
28
42
  !column.null
29
43
  end
30
44
 
@@ -49,7 +63,7 @@ module ActiveRecordDoctor
49
63
  model.validators.any? do |validator|
50
64
  validator.is_a?(ActiveModel::Validations::ExclusionValidator) &&
51
65
  validator.attributes.include?(column.name.to_sym) &&
52
- validator.options.fetch(:in, []).include?(nil)
66
+ validator.options.fetch(:in, []).include?(nil)
53
67
  end
54
68
  end
55
69