active_record_doctor 1.10.0 → 1.11.0

Sign up to get free protection for your applications and to get access to all the features.
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]