active_record_doctor 1.10.0 → 1.11.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/active_record_doctor/detectors/base.rb +180 -50
  4. data/lib/active_record_doctor/detectors/extraneous_indexes.rb +24 -27
  5. data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +2 -5
  6. data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +63 -21
  7. data/lib/active_record_doctor/detectors/incorrect_length_validation.rb +7 -10
  8. data/lib/active_record_doctor/detectors/mismatched_foreign_key_type.rb +16 -9
  9. data/lib/active_record_doctor/detectors/missing_foreign_keys.rb +2 -4
  10. data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +13 -11
  11. data/lib/active_record_doctor/detectors/missing_presence_validation.rb +14 -7
  12. data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +5 -11
  13. data/lib/active_record_doctor/detectors/short_primary_key_type.rb +1 -1
  14. data/lib/active_record_doctor/detectors/undefined_table_references.rb +2 -2
  15. data/lib/active_record_doctor/detectors/unindexed_deleted_at.rb +5 -13
  16. data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +2 -4
  17. data/lib/active_record_doctor/logger/dummy.rb +11 -0
  18. data/lib/active_record_doctor/logger/hierarchical.rb +22 -0
  19. data/lib/active_record_doctor/logger.rb +6 -0
  20. data/lib/active_record_doctor/rake/task.rb +10 -1
  21. data/lib/active_record_doctor/runner.rb +8 -3
  22. data/lib/active_record_doctor/version.rb +1 -1
  23. data/lib/active_record_doctor.rb +3 -0
  24. data/test/active_record_doctor/detectors/disable_test.rb +1 -1
  25. data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +7 -7
  26. data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +136 -57
  27. data/test/active_record_doctor/detectors/incorrect_length_validation_test.rb +16 -14
  28. data/test/active_record_doctor/detectors/mismatched_foreign_key_type_test.rb +35 -1
  29. data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +46 -23
  30. data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +55 -27
  31. data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +36 -36
  32. data/test/active_record_doctor/detectors/undefined_table_references_test.rb +11 -13
  33. data/test/active_record_doctor/runner_test.rb +18 -19
  34. data/test/setup.rb +10 -6
  35. metadata +19 -4
  36. data/test/model_factory.rb +0 -128
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e1bf7642b1c7b471fbf78956825067ff070d261d4c47a182d1aaf00f10b98b7
4
- data.tar.gz: 6e6fc746327d6db30c282d7964ae778b254107281d9626a251f8593d6ce85db4
3
+ metadata.gz: fef82b1493488683e11bc72273e142479cb47234651a8aa772a83121960f9309
4
+ data.tar.gz: c99afc25d8307ff0814e0459790f3d7fcf0b015d9f37f118870a9a1cfa2cf460
5
5
  SHA512:
6
- metadata.gz: a0a2d71947728b9d6a130901d94d1ed8c2ede9dde209e91897a8beb8b757da2fea854b89a3a3f6223d658c203e75e687edbee2af37b2203ddb24b28e831439e8
7
- data.tar.gz: f0fe87dd6acf379ef946bc513504342c3977bf5c4d7f71eca79290c693b318f0a838c31b95111f68635579cdfa26249d5a95fd456817b6a3c32023c1b4fe0fd1
6
+ metadata.gz: ab75ff192c31cfc10cbd5c4106ca0033cc4d408c1e4977a417f76c53f2a2e73e37e5706162cd4807c700808e171756ddda022af6658b60f460d78109f9bea2ee
7
+ data.tar.gz: 075625a08ac5f192a68ed6c99409a4acf85cadb77ec117d6933186c2bf4d07e5aaf08928e9f30d5dd96b263e8e530a4a4da848ef5a4dfc82d5dae0df2475f7d2
data/README.md CHANGED
@@ -509,7 +509,7 @@ Supported configuration options:
509
509
 
510
510
  - `enabled` - set to `false` to disable the detector altogether
511
511
  - `ignore_models` - models whose associations should not be checked.
512
- - `ignore_columns` - associations, written as Model.association, that should not
512
+ - `ignore_associations` - associations, written as Model.association, that should not
513
513
  be checked.
514
514
 
515
515
  ### Detecting Primary Keys Having Short Integer Types
@@ -13,8 +13,8 @@ module ActiveRecordDoctor
13
13
  class << self
14
14
  attr_reader :description
15
15
 
16
- def run(config, io)
17
- new(config, io).run
16
+ def run(*args, **kwargs, &block)
17
+ new(*args, **kwargs, &block).run
18
18
  end
19
19
 
20
20
  def underscored_name
@@ -38,23 +38,36 @@ module ActiveRecordDoctor
38
38
  end
39
39
  end
40
40
 
41
- def initialize(config, io)
41
+ def initialize(config:, logger:, io:)
42
42
  @problems = []
43
43
  @config = config
44
+ @logger = logger
44
45
  @io = io
45
46
  end
46
47
 
47
48
  def run
48
- @problems = []
49
+ log(underscored_name) do
50
+ @problems = []
49
51
 
50
- detect if config(:enabled)
51
- @problems.each do |problem|
52
- @io.puts(message(**problem))
53
- end
52
+ if config(:enabled)
53
+ detect
54
+ else
55
+ log("disabled; skipping")
56
+ end
57
+
58
+ @problems.each do |problem|
59
+ @io.puts(message(**problem))
60
+ end
54
61
 
55
- success = @problems.empty?
56
- @problems = nil
57
- success
62
+ success = @problems.empty?
63
+ if success
64
+ log("No problems found")
65
+ else
66
+ log("Found #{@problems.count} problem(s)")
67
+ end
68
+ @problems = nil
69
+ success
70
+ end
58
71
  end
59
72
 
60
73
  private
@@ -79,7 +92,16 @@ module ActiveRecordDoctor
79
92
  raise("#message should be implemented by a subclass")
80
93
  end
81
94
 
95
+ def log(message, &block)
96
+ @logger.log(message, &block)
97
+ end
98
+
82
99
  def problem!(**attrs)
100
+ log("Problem found") do
101
+ attrs.each do |key, value|
102
+ log("#{key}: #{value.inspect}")
103
+ end
104
+ end
83
105
  @problems << attrs
84
106
  end
85
107
 
@@ -91,23 +113,8 @@ module ActiveRecordDoctor
91
113
  @connection ||= ActiveRecord::Base.connection
92
114
  end
93
115
 
94
- def indexes(table_name, except: [])
95
- connection.indexes(table_name).reject do |index|
96
- except.include?(index.name)
97
- end
98
- end
99
-
100
- def tables(except: [])
101
- tables =
102
- if ActiveRecord::VERSION::STRING >= "5.1"
103
- connection.tables
104
- else
105
- connection.data_sources
106
- end
107
-
108
- tables.reject do |table|
109
- except.include?(table)
110
- end
116
+ def indexes(table_name)
117
+ connection.indexes(table_name)
111
118
  end
112
119
 
113
120
  def primary_key(table_name)
@@ -121,24 +128,6 @@ module ActiveRecordDoctor
121
128
  connection.columns(table_name).find { |column| column.name == column_name }
122
129
  end
123
130
 
124
- def views
125
- @views ||=
126
- if connection.respond_to?(:views)
127
- connection.views
128
- elsif postgresql?
129
- ActiveRecord::Base.connection.select_values(<<-SQL)
130
- SELECT relname FROM pg_class WHERE relkind IN ('m', 'v')
131
- SQL
132
- elsif connection.adapter_name == "Mysql2"
133
- ActiveRecord::Base.connection.select_values(<<-SQL)
134
- SHOW FULL TABLES WHERE table_type = 'VIEW'
135
- SQL
136
- else
137
- # We don't support this Rails/database combination yet.
138
- []
139
- end
140
- end
141
-
142
131
  def not_null_check_constraint_exists?(table, column)
143
132
  check_constraints(table).any? do |definition|
144
133
  definition =~ /\A#{column.name} IS NOT NULL\z/i ||
@@ -167,10 +156,8 @@ module ActiveRecordDoctor
167
156
  end
168
157
  end
169
158
 
170
- def models(except: [])
171
- ActiveRecord::Base.descendants.reject do |model|
172
- model.name.start_with?("HABTM_") || except.include?(model.name)
173
- end
159
+ def models
160
+ ActiveRecord::Base.descendants
174
161
  end
175
162
 
176
163
  def underscored_name
@@ -180,6 +167,149 @@ module ActiveRecordDoctor
180
167
  def postgresql?
181
168
  ["PostgreSQL", "PostGIS"].include?(connection.adapter_name)
182
169
  end
170
+
171
+ def each_model(except: [], abstract: nil, existing_tables_only: false)
172
+ log("Iterating over Active Record models") do
173
+ models.each do |model|
174
+ case
175
+ when model.name.start_with?("HABTM_")
176
+ log("#{model.name} - has-belongs-to-many model; skipping")
177
+ when except.include?(model.name)
178
+ log("#{model.name} - ignored via the configuration; skipping")
179
+ when abstract && !model.abstract_class?
180
+ log("#{model.name} - non-abstract model; skipping")
181
+ when abstract == false && model.abstract_class?
182
+ log("#{model.name} - abstract model; skipping")
183
+ when existing_tables_only && (model.table_name.nil? || !model.table_exists?)
184
+ log("#{model.name} - backed by a non-existent table #{model.table_name}; skipping")
185
+ else
186
+ log(model.name) do
187
+ yield(model)
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
193
+
194
+ def each_index(table_name, except: [], multicolumn_only: false)
195
+ indexes = connection.indexes(table_name)
196
+
197
+ message =
198
+ if multicolumn_only
199
+ "Iterating over multi-column indexes on #{table_name}"
200
+ else
201
+ "Iterating over indexes on #{table_name}"
202
+ end
203
+
204
+ log(message) do
205
+ indexes.each do |index|
206
+ case
207
+ when except.include?(index.name)
208
+ log("#{index.name} - ignored via the configuration; skipping")
209
+ when multicolumn_only && !index.columns.is_a?(Array)
210
+ log("#{index.name} - single-column index; skipping")
211
+ else
212
+ log("Index #{index.name} on #{table_name}") do
213
+ yield(index, indexes)
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
219
+
220
+ def each_attribute(model, except: [], type: nil)
221
+ log("Iterating over attributes of #{model.name}") do
222
+ connection.columns(model.table_name).each do |column|
223
+ case
224
+ when except.include?("#{model.name}.#{column.name}")
225
+ log("#{model.name}.#{column.name} - ignored via the configuration; skipping")
226
+ when type && !Array(type).include?(column.type)
227
+ log("#{model.name}.#{column.name} - ignored due to the #{column.type} type; skipping")
228
+ else
229
+ log("#{model.name}.#{column.name}") do
230
+ yield(column)
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
236
+
237
+ def each_column(table_name, only: nil, except: [])
238
+ log("Iterating over columns of #{table_name}") do
239
+ connection.columns(table_name).each do |column|
240
+ case
241
+ when except.include?("#{table_name}.#{column.name}")
242
+ log("#{column.name} - ignored via the configuration; skipping")
243
+ when only.nil? || only.include?(column.name)
244
+ log(column.name.to_s) do
245
+ yield(column)
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
251
+
252
+ def each_foreign_key(table_name)
253
+ log("Iterating over foreign keys on #{table_name}") do
254
+ connection.foreign_keys(table_name).each do |foreign_key|
255
+ log("#{foreign_key.name} - #{foreign_key.from_table}(#{foreign_key.options[:column]}) to #{foreign_key.to_table}(#{foreign_key.options[:primary_key]})") do # rubocop:disable Layout/LineLength
256
+ yield(foreign_key)
257
+ end
258
+ end
259
+ end
260
+ end
261
+
262
+ def each_table(except: [])
263
+ tables =
264
+ if ActiveRecord::VERSION::STRING >= "5.1"
265
+ connection.tables
266
+ else
267
+ connection.data_sources
268
+ end
269
+
270
+ log("Iterating over tables") do
271
+ tables.each do |table|
272
+ case
273
+ when except.include?(table)
274
+ log("#{table} - ignored via the configuration; skipping")
275
+ else
276
+ log(table) do
277
+ yield(table)
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end
283
+
284
+ def each_association(model, except: [], type: [:has_many, :has_one, :belongs_to], has_scope: nil, through: nil)
285
+ type = Array(type)
286
+
287
+ log("Iterating over associations on #{model.name}") do
288
+ associations = []
289
+ type.each do |type1|
290
+ associations.concat(model.reflect_on_all_associations(type1))
291
+ end
292
+
293
+ associations.each do |association|
294
+ case
295
+ when except.include?("#{model.name}.#{association.name}")
296
+ log("#{model.name}.#{association.name} - ignored via the configuration; skipping")
297
+ when through && !association.is_a?(ActiveRecord::Reflection::ThroughReflection)
298
+ log("#{model.name}.#{association.name} - is not a through association; skipping")
299
+ when through == false && association.is_a?(ActiveRecord::Reflection::ThroughReflection)
300
+ log("#{model.name}.#{association.name} - is a through association; skipping")
301
+ when has_scope && association.scope.nil?
302
+ log("#{model.name}.#{association.name} - doesn't have a scope; skipping")
303
+ when has_scope == false && association.scope
304
+ log("#{model.name}.#{association.name} - has a scope; skipping")
305
+ else
306
+ log("#{association.macro} :#{association.name}") do
307
+ yield(association)
308
+ end
309
+ end
310
+ end
311
+ end
312
+ end
183
313
  end
184
314
  end
185
315
  end
@@ -33,35 +33,36 @@ module ActiveRecordDoctor
33
33
  end
34
34
 
35
35
  def subindexes_of_multi_column_indexes
36
- tables(except: config(:ignore_tables)).each do |table|
37
- indexes = indexes(table)
38
-
39
- indexes.each do |index|
40
- next if config(:ignore_indexes).include?(index.name)
41
-
42
- replacement_indexes = indexes.select do |other_index|
43
- index != other_index && replaceable_with?(index, other_index)
36
+ log(__method__) do
37
+ each_table(except: config(:ignore_tables)) do |table|
38
+ each_index(table, except: config(:ignore_indexes), multicolumn_only: true) do |index, indexes|
39
+ replacement_indexes = indexes.select do |other_index|
40
+ index != other_index && replaceable_with?(index, other_index)
41
+ end
42
+
43
+ if replacement_indexes.empty?
44
+ log("Found no replacement indexes; skipping")
45
+ next
46
+ end
47
+
48
+ problem!(
49
+ extraneous_index: index.name,
50
+ replacement_indexes: replacement_indexes.map(&:name).sort
51
+ )
44
52
  end
45
-
46
- next if replacement_indexes.empty?
47
-
48
- problem!(
49
- extraneous_index: index.name,
50
- replacement_indexes: replacement_indexes.map(&:name).sort
51
- )
52
53
  end
53
54
  end
54
55
  end
55
56
 
56
57
  def indexed_primary_keys
57
- tables(except: config(:ignore_tables)).each do |table|
58
- indexes(table).each do |index|
59
- next if config(:ignore_indexes).include?(index.name)
60
-
61
- primary_key = connection.primary_key(table)
62
- next if index.columns != [primary_key] || index.where
63
-
64
- problem!(extraneous_index: index.name, replacement_indexes: nil)
58
+ log(__method__) do
59
+ each_table(except: config(:ignore_tables)) do |table|
60
+ each_index(table, except: config(:ignore_indexes), multicolumn_only: true) do |index|
61
+ primary_key = connection.primary_key(table)
62
+ if index.columns == [primary_key] && index.where.nil?
63
+ problem!(extraneous_index: index.name, replacement_indexes: nil)
64
+ end
65
+ end
65
66
  end
66
67
  end
67
68
  end
@@ -90,10 +91,6 @@ module ActiveRecordDoctor
90
91
  lhs.columns.count <= rhs.columns.count &&
91
92
  rhs.columns[0...lhs.columns.count] == lhs.columns
92
93
  end
93
-
94
- def indexes(table_name)
95
- super.select { |index| index.columns.is_a?(Array) }
96
- end
97
94
  end
98
95
  end
99
96
  end
@@ -25,11 +25,8 @@ module ActiveRecordDoctor
25
25
  end
26
26
 
27
27
  def detect
28
- models(except: config(:ignore_models)).each do |model|
29
- next unless model.table_exists?
30
-
31
- connection.columns(model.table_name).each do |column|
32
- next if config(:ignore_attributes).include?("#{model.name}.#{column.name}")
28
+ each_model(except: config(:ignore_models), existing_tables_only: true) do |model|
29
+ each_attribute(model, except: config(:ignore_attributes)) do |column|
33
30
  next unless column.type == :boolean
34
31
  next unless has_presence_validator?(model, column)
35
32
 
@@ -18,7 +18,7 @@ module ActiveRecordDoctor
18
18
 
19
19
  private
20
20
 
21
- def message(model:, association:, problem:, associated_models:)
21
+ def message(model:, association:, problem:, associated_models:, associated_models_type:)
22
22
  associated_models.sort!
23
23
 
24
24
  models_part =
@@ -28,34 +28,62 @@ module ActiveRecordDoctor
28
28
  "models #{associated_models.join(', ')} have"
29
29
  end
30
30
 
31
+ if associated_models_type
32
+ models_part = "#{associated_models_type} #{models_part}"
33
+ end
34
+
31
35
  # rubocop:disable Layout/LineLength
32
36
  case problem
37
+ when :invalid_through
38
+ "ensure #{model}.#{association} is configured correctly - #{associated_models[0]}.#{association} may be undefined"
33
39
  when :suggest_destroy
34
- "use `dependent: :destroy` or similar on #{model}.#{association} - the associated #{models_part} callbacks that are currently skipped"
40
+ "use `dependent: :destroy` or similar on #{model}.#{association} - associated #{models_part} callbacks that are currently skipped"
35
41
  when :suggest_delete
36
- "use `dependent: :delete` or similar on #{model}.#{association} - the associated #{models_part} no callbacks and can be deleted without loading"
42
+ "use `dependent: :delete` or similar on #{model}.#{association} - associated #{models_part} no callbacks and can be deleted without loading"
37
43
  when :suggest_delete_all
38
- "use `dependent: :delete_all` or similar on #{model}.#{association} - associated #{models_part} no validations and can be deleted in bulk"
44
+ "use `dependent: :delete_all` or similar on #{model}.#{association} - associated #{models_part} no callbacks and can be deleted in bulk"
39
45
  end
40
46
  # rubocop:enable Layout/LineLength
41
47
  end
42
48
 
43
49
  def detect
44
- models(except: config(:ignore_models)).each do |model|
45
- next unless model.table_exists?
46
-
47
- associations = model.reflect_on_all_associations(:has_many) +
48
- model.reflect_on_all_associations(:has_one) +
49
- model.reflect_on_all_associations(:belongs_to)
50
-
51
- associations.each do |association|
52
- next if config(:ignore_associations).include?("#{model.name}.#{association.name}")
50
+ each_model(except: config(:ignore_models), existing_tables_only: true) do |model|
51
+ each_association(model, except: config(:ignore_associations)) do |association|
52
+ # A properly configured :through association will have a non-nil
53
+ # source_reflection. If it's nil then it indicates the :through
54
+ # model lacks the next leg in the :through relationship. For
55
+ # instance, if user has many comments through posts then a nil
56
+ # source_reflection means that Post doesn't define +has_many :comments+.
57
+ if through?(association) && association.source_reflection.nil?
58
+ log("through association with nil source_reflection")
59
+
60
+ through_association = model.reflect_on_association(association.options.fetch(:through))
61
+ association_on_join_model = through_association.klass.reflect_on_association(association.name)
62
+
63
+ # We report a problem only if the +has_many+ association mentioned
64
+ # above is actually missing. We let the detector continue in other
65
+ # cases, risking an exception, as the absence of source_reflection
66
+ # must be caused by something else in those cases. Each further
67
+ # exception will be handled on a case-by-case basis.
68
+ if association_on_join_model.nil?
69
+ problem!(
70
+ model: model.name,
71
+ association: association.name,
72
+ problem: :invalid_through,
73
+ associated_models: [through_association.klass.name],
74
+ associated_models_type: "join"
75
+ )
76
+ next
77
+ end
78
+ end
53
79
 
54
- associated_models =
80
+ associated_models, associated_models_type =
55
81
  if association.polymorphic?
56
- models_having(as: association.name)
82
+ [models_having_association_with_options(as: association.name), nil]
83
+ elsif through?(association)
84
+ [[association.source_reflection.active_record], "join"]
57
85
  else
58
- [association.klass]
86
+ [[association.klass], nil]
59
87
  end
60
88
 
61
89
  deletable_models, destroyable_models = associated_models.partition { |klass| deletable?(klass) }
@@ -68,17 +96,27 @@ module ActiveRecordDoctor
68
96
  else raise("unsupported association type #{association.macro}")
69
97
  end
70
98
 
71
- problem!(model: model.name, association: association.name, problem: suggestion,
72
- associated_models: deletable_models.map(&:name))
99
+ problem!(
100
+ model: model.name,
101
+ association: association.name,
102
+ problem: suggestion,
103
+ associated_models: deletable_models.map(&:name),
104
+ associated_models_type: associated_models_type
105
+ )
73
106
  elsif callback_action(association) == :skip && destroyable_models.present?
74
- problem!(model: model.name, association: association.name, problem: :suggest_destroy,
75
- associated_models: destroyable_models.map(&:name))
107
+ problem!(
108
+ model: model.name,
109
+ association: association.name,
110
+ problem: :suggest_destroy,
111
+ associated_models: destroyable_models.map(&:name),
112
+ associated_models_type: associated_models_type
113
+ )
76
114
  end
77
115
  end
78
116
  end
79
117
  end
80
118
 
81
- def models_having(as:)
119
+ def models_having_association_with_options(as:)
82
120
  models.select do |model|
83
121
  associations = model.reflect_on_all_associations(:has_one) +
84
122
  model.reflect_on_all_associations(:has_many)
@@ -108,6 +146,10 @@ module ActiveRecordDoctor
108
146
  end
109
147
  end
110
148
 
149
+ def through?(reflection)
150
+ reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
151
+ end
152
+
111
153
  def defines_destroy_callbacks?(model)
112
154
  # Destroying an associated model involves loading it first hence
113
155
  # initialize and find are present. If they are defined on the model
@@ -31,14 +31,9 @@ module ActiveRecordDoctor
31
31
  end
32
32
 
33
33
  def detect
34
- models(except: config(:ignore_models)).each do |model|
35
- next unless model.table_exists?
36
-
37
- connection.columns(model.table_name).each do |column|
38
- next if config(:ignore_attributes).include?("#{model.name}.#{column.name}")
39
- next if ![:string, :text].include?(column.type)
40
-
41
- model_maximum = maximum_allowed_by_validations(model)
34
+ each_model(except: config(:ignore_models), existing_tables_only: true) do |model|
35
+ each_attribute(model, except: config(:ignore_attributes), type: [:string, :text]) do |column|
36
+ model_maximum = maximum_allowed_by_validations(model, column.name.to_sym)
42
37
  next if model_maximum == column.limit
43
38
 
44
39
  problem!(
@@ -52,9 +47,11 @@ module ActiveRecordDoctor
52
47
  end
53
48
  end
54
49
 
55
- def maximum_allowed_by_validations(model)
50
+ def maximum_allowed_by_validations(model, column)
56
51
  length_validator = model.validators.find do |validator|
57
- validator.kind == :length && validator.options.include?(:maximum)
52
+ validator.kind == :length &&
53
+ validator.options.include?(:maximum) &&
54
+ validator.attributes.include?(column)
58
55
  end
59
56
  length_validator ? length_validator.options[:maximum] : nil
60
57
  end
@@ -18,25 +18,32 @@ module ActiveRecordDoctor
18
18
 
19
19
  private
20
20
 
21
- def message(table:, column:)
21
+ def message(from_table:, from_column:, from_type:, to_table:, to_column:, to_type:)
22
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"
23
+ "#{from_table}.#{from_column} is a foreign key of type #{from_type} and references #{to_table}.#{to_column} of type #{to_type} - foreign keys should be of the same type as the referenced column"
24
24
  # rubocop:enable Layout/LineLength
25
25
  end
26
26
 
27
27
  def detect
28
- tables(except: config(:ignore_tables)).each do |table|
29
- connection.foreign_keys(table).each do |foreign_key|
28
+ each_table(except: config(:ignore_tables)) do |table|
29
+ each_foreign_key(table) do |foreign_key|
30
30
  from_column = column(table, foreign_key.column)
31
31
 
32
32
  next if config(:ignore_columns).include?("#{table}.#{from_column.name}")
33
33
 
34
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)
35
+ to_column = column(to_table, foreign_key.primary_key)
36
+
37
+ next if from_column.sql_type == to_column.sql_type
38
+
39
+ problem!(
40
+ from_table: table,
41
+ from_column: from_column.name,
42
+ from_type: from_column.sql_type,
43
+ to_table: to_table,
44
+ to_column: to_column.name,
45
+ to_type: to_column.sql_type
46
+ )
40
47
  end
41
48
  end
42
49
  end
@@ -23,10 +23,8 @@ module ActiveRecordDoctor
23
23
  end
24
24
 
25
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
-
26
+ each_table(except: config(:ignore_tables)) do |table|
27
+ each_column(table, except: config(:ignore_columns)) do |column|
30
28
  # We need to skip polymorphic associations as they can reference
31
29
  # multiple tables but a foreign key constraint can reference
32
30
  # a single predefined table.
@@ -23,8 +23,7 @@ module ActiveRecordDoctor
23
23
  end
24
24
 
25
25
  def detect
26
- table_models = models.group_by(&:table_name)
27
- table_models.delete_if { |_table, models| !models.first.table_exists? }
26
+ table_models = models.select(&:table_exists?).group_by(&:table_name)
28
27
 
29
28
  table_models.each do |table, models|
30
29
  next if config(:ignore_tables).include?(table)
@@ -50,19 +49,22 @@ module ActiveRecordDoctor
50
49
  end
51
50
 
52
51
  def non_null_needed?(model, column)
53
- # A foreign key can be validates via the column name (e.g. company_id)
54
- # or the association name (e.g. company). We collect the allowed names
55
- # in an array to check for their presence in the validator definition
56
- # in one go.
57
- attribute_name_forms = [column.name.to_sym]
58
52
  belongs_to = model.reflect_on_all_associations(:belongs_to).find do |reflection|
59
- reflection.foreign_key == column.name
53
+ reflection.foreign_key == column.name ||
54
+ (reflection.polymorphic? && reflection.foreign_type == column.name)
60
55
  end
61
- attribute_name_forms << belongs_to.name.to_sym if belongs_to
62
56
 
63
- model.validators.any? do |validator|
57
+ required_presence_validators(model).any? do |validator|
58
+ attributes = validator.attributes
59
+
60
+ attributes.include?(column.name.to_sym) ||
61
+ (belongs_to && attributes.include?(belongs_to.name.to_sym))
62
+ end
63
+ end
64
+
65
+ def required_presence_validators(model)
66
+ model.validators.select do |validator|
64
67
  validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
65
- (validator.attributes & attribute_name_forms).present? &&
66
68
  !validator.options[:allow_nil] &&
67
69
  !validator.options[:if] &&
68
70
  !validator.options[:unless]