active_record_doctor 1.11.0 → 1.13.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -14
  3. data/lib/active_record_doctor/config/loader.rb +1 -1
  4. data/lib/active_record_doctor/detectors/base.rb +30 -15
  5. data/lib/active_record_doctor/detectors/extraneous_indexes.rb +14 -9
  6. data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +1 -1
  7. data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +44 -31
  8. data/lib/active_record_doctor/detectors/mismatched_foreign_key_type.rb +1 -1
  9. data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +2 -2
  10. data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +73 -23
  11. data/lib/active_record_doctor/detectors/short_primary_key_type.rb +3 -3
  12. data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +34 -7
  13. data/lib/active_record_doctor/logger/hierarchical.rb +1 -1
  14. data/lib/active_record_doctor/railtie.rb +1 -1
  15. data/lib/active_record_doctor/runner.rb +1 -1
  16. data/lib/active_record_doctor/utils.rb +21 -0
  17. data/lib/active_record_doctor/version.rb +1 -1
  18. data/lib/active_record_doctor.rb +2 -0
  19. data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +14 -14
  20. data/lib/tasks/active_record_doctor.rake +2 -2
  21. metadata +11 -47
  22. data/test/active_record_doctor/config/loader_test.rb +0 -120
  23. data/test/active_record_doctor/config_test.rb +0 -116
  24. data/test/active_record_doctor/detectors/disable_test.rb +0 -30
  25. data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +0 -224
  26. data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +0 -79
  27. data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +0 -472
  28. data/test/active_record_doctor/detectors/incorrect_length_validation_test.rb +0 -107
  29. data/test/active_record_doctor/detectors/mismatched_foreign_key_type_test.rb +0 -116
  30. data/test/active_record_doctor/detectors/missing_foreign_keys_test.rb +0 -70
  31. data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +0 -273
  32. data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +0 -232
  33. data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +0 -327
  34. data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +0 -72
  35. data/test/active_record_doctor/detectors/undefined_table_references_test.rb +0 -55
  36. data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +0 -177
  37. data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +0 -78
  38. data/test/active_record_doctor/runner_test.rb +0 -41
  39. data/test/generators/active_record_doctor/add_indexes/add_indexes_generator_test.rb +0 -131
  40. data/test/setup.rb +0 -124
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fef82b1493488683e11bc72273e142479cb47234651a8aa772a83121960f9309
4
- data.tar.gz: c99afc25d8307ff0814e0459790f3d7fcf0b015d9f37f118870a9a1cfa2cf460
3
+ metadata.gz: cc970595479a84692995f75b3e512f1b1a01dc6fc61eee9a738abbd402ac9e44
4
+ data.tar.gz: 3bd5807bc126459379478c24d14a5595a2ee207415cb71c86c82ebdfae5f19f3
5
5
  SHA512:
6
- metadata.gz: ab75ff192c31cfc10cbd5c4106ca0033cc4d408c1e4977a417f76c53f2a2e73e37e5706162cd4807c700808e171756ddda022af6658b60f460d78109f9bea2ee
7
- data.tar.gz: 075625a08ac5f192a68ed6c99409a4acf85cadb77ec117d6933186c2bf4d07e5aaf08928e9f30d5dd96b263e8e530a4a4da848ef5a4dfc82d5dae0df2475f7d2
6
+ metadata.gz: 4b0ac66a6f9037ce810ff73660735b816bc58d614dd7612b7c7849292741519cbecd6bdb7105c7392e01fc1621ce550e1b8ba0726573beca33ee6e10ec6e35da
7
+ data.tar.gz: c3d2d7b0448ccff98001c72fa6330b54f45364a43e3eff35ff7407d0e2aeaaf8c3070a3ce308992577fa41adf7cdde3a8ba2aa93db4c8955fd69e5ec7f29d03b
data/README.md CHANGED
@@ -7,7 +7,7 @@ can detect:
7
7
  * unindexed `deleted_at` columns - [`active_record_doctor:unindexed_deleted_at`](#detecting-unindexed-deleted_at-columns)
8
8
  * missing foreign key constraints - [`active_record_doctor:missing_foreign_keys`](#detecting-missing-foreign-key-constraints)
9
9
  * models referencing undefined tables - [`active_record_doctor:undefined_table_references`](#detecting-models-referencing-undefined-tables)
10
- * uniqueness validations not backed by an unique index - [`active_record_doctor:missing_unique_indexes`](#detecting-uniqueness-validations-not-backed-by-an-index)
10
+ * uniqueness validations not backed by a unique index - [`active_record_doctor:missing_unique_indexes`](#detecting-uniqueness-validations-not-backed-by-an-index)
11
11
  * missing non-`NULL` constraints - [`active_record_doctor:missing_non_null_constraint`](#detecting-missing-non-null-constraints)
12
12
  * missing presence validations - [`active_record_doctor:missing_presence_validation`](#detecting-missing-presence-validations)
13
13
  * incorrect presence validations on boolean columns - [`active_record_doctor:incorrect_boolean_presence_validation`](#detecting-incorrect-presence-validations-on-boolean-columns)
@@ -144,6 +144,33 @@ as extraneous.
144
144
  Configuration options for each detector are listed below. They can also be
145
145
  obtained via the help mechanism described in the previous section.
146
146
 
147
+ ### Regexp-Based Ignores
148
+
149
+ Settings like `ignore_tables`, `ignore_indexes`, and so on accept list of
150
+ identifiers to ignore. These can be either:
151
+
152
+ 1. Strings - in which case an exact match is needed.
153
+ 2. Regexps - which are matched against object names, and matching ones are
154
+ excluded from output.
155
+
156
+ For example, to ignore all tables starting with `legacy_` you can write:
157
+
158
+ ```ruby
159
+ ActiveRecordDoctor.configure do
160
+ global :ignore_tables, [
161
+ # Ignore internal Rails-related tables.
162
+ "ar_internal_metadata",
163
+ "schema_migrations",
164
+ "active_storage_blobs",
165
+ "active_storage_attachments",
166
+ "action_text_rich_texts",
167
+
168
+ # Ignore all legacy tables.
169
+ /^legacy_/
170
+ ]
171
+ end
172
+ ```
173
+
147
174
  ### Indexing Unindexed Foreign Keys
148
175
 
149
176
  Foreign keys should be indexed unless it's proven ineffective. However, Rails
@@ -158,7 +185,7 @@ three-step process:
158
185
  ```
159
186
 
160
187
  2. Remove columns that should _not_ be indexed from `unindexed_foreign_keys.txt`
161
- as a column can look like a foreign key (i.e. end with `_id`) without being
188
+ as a column can look like a foreign key (i.e. ending with `_id`) without being
162
189
  one.
163
190
 
164
191
  3. Generate the migrations
@@ -209,7 +236,7 @@ To discover such indexes automatically just follow these steps:
209
236
 
210
237
  3. Create a migration to drop the indexes.
211
238
 
212
- The indexes aren't dropped automatically because there's usually just a few of
239
+ The indexes aren't dropped automatically because there are usually just a few of
213
240
  them and it's a good idea to double-check that you won't drop something
214
241
  necessary.
215
242
 
@@ -265,11 +292,11 @@ Supported configuration options:
265
292
  If `users.profile_id` references a row in `profiles` then this can be expressed
266
293
  at the database level with a foreign key constraint. It _forces_
267
294
  `users.profile_id` to point to an existing row in `profiles`. The problem is
268
- that in many legacy Rails apps the constraint isn't enforced at the database
295
+ that in many legacy Rails apps, the constraint isn't enforced at the database
269
296
  level.
270
297
 
271
298
  `active_record_doctor` can automatically detect foreign keys that could benefit
272
- from a foreign key constraint (a future version will generate a migrations that
299
+ from a foreign key constraint (a future version will generate a migration that
273
300
  add the constraint; for now, it's your job). You can obtain the list of foreign
274
301
  keys with the following command:
275
302
 
@@ -307,7 +334,7 @@ before they hit production.
307
334
  * Rails 5+ and _any_ database or
308
335
  * Rails 4.2 with PostgreSQL.
309
336
 
310
- The only think you need to do is run:
337
+ The only thing you need to do is run:
311
338
 
312
339
  ```
313
340
  bundle exec rake active_record_doctor:undefined_table_references
@@ -320,7 +347,7 @@ this:
320
347
  Contract references a non-existent table or view named contract_records
321
348
  ```
322
349
 
323
- On top of that `rake` will exit with status code of 1. This allows you to use
350
+ On top of that `rake` will exit with a status code of 1. This allows you to use
324
351
  this check as part of your Continuous Integration pipeline.
325
352
 
326
353
  Supported configuration options:
@@ -333,7 +360,7 @@ Supported configuration options:
333
360
 
334
361
  Model-level uniqueness validations and `has_one` associations should be backed
335
362
  by a database index in order to be robust. Otherwise you risk inserting
336
- duplicate values under heavy load.
363
+ duplicate values under a heavy load.
337
364
 
338
365
  In order to detect such validations run:
339
366
 
@@ -475,7 +502,7 @@ Supported configuration options:
475
502
 
476
503
  - `enabled` - set to `false` to disable the detector altogether
477
504
  - `ignore_models` - models whose validators should not be checked.
478
- - `ignore_columns` - attributes, written as Model.attribute, whose validators
505
+ - `ignore_attributes` - attributes, written as Model.attribute, whose validators
479
506
  should not be checked.
480
507
 
481
508
  ### Detecting Incorrect `dependent` Option on Associations
@@ -489,7 +516,7 @@ This can lead to two types of errors:
489
516
  - Using `delete_all` when dependent models define callbacks - they will NOT be
490
517
  invoked.
491
518
  - Using `destroy` when dependent models define no callbacks - dependent models
492
- will be loaded one-by-one with no reason
519
+ will be loaded one by one with no reason
493
520
 
494
521
  In order to detect associations affected by the two aforementioned problems run
495
522
  the following command:
@@ -530,8 +557,8 @@ The output of the command looks like this:
530
557
  change the type of companies.id to bigint
531
558
  ```
532
559
 
533
- The above means `comanies.id` should be migrated to a wider integer type. An
534
- example migration to accomplish this looks likes this:
560
+ The above means `companies.id` should be migrated to a wider integer type. An
561
+ example migration to accomplish this looks like this:
535
562
 
536
563
  ```ruby
537
564
  class ChangeCompaniesPrimaryKeyType < ActiveRecord::Migration[5.1]
@@ -565,7 +592,7 @@ bundle exec rake active_record_doctor:mismatched_foreign_key_type
565
592
  The output of the command looks like this:
566
593
 
567
594
  ```
568
- companies.user_id references a column of different type - foreign keys should be of the same type as the referenced column
595
+ companies.user_id references a column of a different type - foreign keys should be of the same type as the referenced column
569
596
  ```
570
597
 
571
598
  Supported configuration options:
@@ -584,7 +611,7 @@ combinations of Ruby and Rails versions. Specifically:
584
611
  supported by `active_record_doctor`.
585
612
  2. If a Ruby version is compatible with a supported Rails version then it's
586
613
  also supported by `active_record_doctor`.
587
- 3. Only most recent teeny Ruby versions and patch Rails versions are supported.
614
+ 3. Only the most recent teeny Ruby versions and patch Rails versions are supported.
588
615
 
589
616
  ## Author
590
617
 
@@ -27,7 +27,7 @@ module ActiveRecordDoctor # :nodoc:
27
27
  end
28
28
 
29
29
  # The same global can be used by multiple detectors so we must remove
30
- # duplicates to ensure they aren't reported mutliple times via the user
30
+ # duplicates to ensure they aren't reported multiple times via the user
31
31
  # interface (e.g. in error messages).
32
32
  recognized_globals.uniq!
33
33
 
@@ -139,7 +139,7 @@ module ActiveRecordDoctor
139
139
  # ActiveRecord 6.1+
140
140
  if connection.respond_to?(:supports_check_constraints?) && connection.supports_check_constraints?
141
141
  connection.check_constraints(table_name).select(&:validated?).map(&:expression)
142
- elsif postgresql?
142
+ elsif Utils.postgresql?(connection)
143
143
  definitions =
144
144
  connection.select_values(<<-SQL)
145
145
  SELECT pg_get_constraintdef(oid, true)
@@ -164,17 +164,13 @@ module ActiveRecordDoctor
164
164
  self.class.underscored_name
165
165
  end
166
166
 
167
- def postgresql?
168
- ["PostgreSQL", "PostGIS"].include?(connection.adapter_name)
169
- end
170
-
171
167
  def each_model(except: [], abstract: nil, existing_tables_only: false)
172
168
  log("Iterating over Active Record models") do
173
169
  models.each do |model|
174
170
  case
175
171
  when model.name.start_with?("HABTM_")
176
172
  log("#{model.name} - has-belongs-to-many model; skipping")
177
- when except.include?(model.name)
173
+ when ignored?(model.name, except)
178
174
  log("#{model.name} - ignored via the configuration; skipping")
179
175
  when abstract && !model.abstract_class?
180
176
  log("#{model.name} - non-abstract model; skipping")
@@ -204,7 +200,7 @@ module ActiveRecordDoctor
204
200
  log(message) do
205
201
  indexes.each do |index|
206
202
  case
207
- when except.include?(index.name)
203
+ when ignored?(index.name, except)
208
204
  log("#{index.name} - ignored via the configuration; skipping")
209
205
  when multicolumn_only && !index.columns.is_a?(Array)
210
206
  log("#{index.name} - single-column index; skipping")
@@ -221,7 +217,7 @@ module ActiveRecordDoctor
221
217
  log("Iterating over attributes of #{model.name}") do
222
218
  connection.columns(model.table_name).each do |column|
223
219
  case
224
- when except.include?("#{model.name}.#{column.name}")
220
+ when ignored?("#{model.name}.#{column.name}", except)
225
221
  log("#{model.name}.#{column.name} - ignored via the configuration; skipping")
226
222
  when type && !Array(type).include?(column.type)
227
223
  log("#{model.name}.#{column.name} - ignored due to the #{column.type} type; skipping")
@@ -238,7 +234,7 @@ module ActiveRecordDoctor
238
234
  log("Iterating over columns of #{table_name}") do
239
235
  connection.columns(table_name).each do |column|
240
236
  case
241
- when except.include?("#{table_name}.#{column.name}")
237
+ when ignored?("#{table_name}.#{column.name}", except)
242
238
  log("#{column.name} - ignored via the configuration; skipping")
243
239
  when only.nil? || only.include?(column.name)
244
240
  log(column.name.to_s) do
@@ -270,7 +266,7 @@ module ActiveRecordDoctor
270
266
  log("Iterating over tables") do
271
267
  tables.each do |table|
272
268
  case
273
- when except.include?(table)
269
+ when ignored?(table, except)
274
270
  log("#{table} - ignored via the configuration; skipping")
275
271
  else
276
272
  log(table) do
@@ -281,18 +277,33 @@ module ActiveRecordDoctor
281
277
  end
282
278
  end
283
279
 
280
+ def each_data_source(except: [])
281
+ log("Iterating over data sources") do
282
+ connection.data_sources.each do |data_source|
283
+ if ignored?(data_source, except)
284
+ log("#{data_source} - ignored via the configuration; skipping")
285
+ else
286
+ log(data_source) do
287
+ yield(data_source)
288
+ end
289
+ end
290
+ end
291
+ end
292
+ end
293
+
284
294
  def each_association(model, except: [], type: [:has_many, :has_one, :belongs_to], has_scope: nil, through: nil)
285
295
  type = Array(type)
286
296
 
287
297
  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
298
+ associations = type.map do |type1|
299
+ # Skip inherited associations from STI to prevent them
300
+ # from being reported multiple times on subclasses.
301
+ model.reflect_on_all_associations(type1) - model.superclass.reflect_on_all_associations(type1)
302
+ end.flatten
292
303
 
293
304
  associations.each do |association|
294
305
  case
295
- when except.include?("#{model.name}.#{association.name}")
306
+ when ignored?("#{model.name}.#{association.name}", except)
296
307
  log("#{model.name}.#{association.name} - ignored via the configuration; skipping")
297
308
  when through && !association.is_a?(ActiveRecord::Reflection::ThroughReflection)
298
309
  log("#{model.name}.#{association.name} - is not a through association; skipping")
@@ -310,6 +321,10 @@ module ActiveRecordDoctor
310
321
  end
311
322
  end
312
323
  end
324
+
325
+ def ignored?(name, patterns)
326
+ patterns.any? { |pattern| pattern === name } # rubocop:disable Style/CaseEquality
327
+ end
313
328
  end
314
329
  end
315
330
  end
@@ -19,11 +19,13 @@ module ActiveRecordDoctor
19
19
 
20
20
  private
21
21
 
22
- def message(extraneous_index:, replacement_indexes:)
22
+ def message(table:, extraneous_index:, replacement_indexes:)
23
23
  if replacement_indexes.nil?
24
- "remove #{extraneous_index} - coincides with the primary key on the table"
24
+ "remove #{extraneous_index} from #{table} - coincides with the primary key on the table"
25
25
  else
26
- "remove #{extraneous_index} - can be replaced by #{replacement_indexes.join(' or ')}"
26
+ # rubocop:disable Layout/LineLength
27
+ "remove the index #{extraneous_index} from the table #{table} - queries should be able to use the following #{'index'.pluralize(replacement_indexes.count)} instead: #{replacement_indexes.join(' or ')}"
28
+ # rubocop:enable Layout/LineLength
27
29
  end
28
30
  end
29
31
 
@@ -34,7 +36,7 @@ module ActiveRecordDoctor
34
36
 
35
37
  def subindexes_of_multi_column_indexes
36
38
  log(__method__) do
37
- each_table(except: config(:ignore_tables)) do |table|
39
+ each_data_source(except: config(:ignore_tables)) do |table|
38
40
  each_index(table, except: config(:ignore_indexes), multicolumn_only: true) do |index, indexes|
39
41
  replacement_indexes = indexes.select do |other_index|
40
42
  index != other_index && replaceable_with?(index, other_index)
@@ -46,6 +48,7 @@ module ActiveRecordDoctor
46
48
  end
47
49
 
48
50
  problem!(
51
+ table: table,
49
52
  extraneous_index: index.name,
50
53
  replacement_indexes: replacement_indexes.map(&:name).sort
51
54
  )
@@ -60,7 +63,7 @@ module ActiveRecordDoctor
60
63
  each_index(table, except: config(:ignore_indexes), multicolumn_only: true) do |index|
61
64
  primary_key = connection.primary_key(table)
62
65
  if index.columns == [primary_key] && index.where.nil?
63
- problem!(extraneous_index: index.name, replacement_indexes: nil)
66
+ problem!(table: table, extraneous_index: index.name, replacement_indexes: nil)
64
67
  end
65
68
  end
66
69
  end
@@ -73,13 +76,16 @@ module ActiveRecordDoctor
73
76
  return false if index1.where != index2.where
74
77
  return false if opclasses(index1) != opclasses(index2)
75
78
 
79
+ index1_columns = Array(index1.columns)
80
+ index2_columns = Array(index2.columns)
81
+
76
82
  case [index1.unique, index2.unique]
77
83
  when [true, true]
78
- (index2.columns - index1.columns).empty?
84
+ (index2_columns - index1_columns).empty?
79
85
  when [true, false]
80
86
  false
81
87
  else
82
- prefix?(index1, index2)
88
+ prefix?(index1_columns, index2_columns)
83
89
  end
84
90
  end
85
91
 
@@ -88,8 +94,7 @@ module ActiveRecordDoctor
88
94
  end
89
95
 
90
96
  def prefix?(lhs, rhs)
91
- lhs.columns.count <= rhs.columns.count &&
92
- rhs.columns[0...lhs.columns.count] == lhs.columns
97
+ lhs.count <= rhs.count && rhs[0...lhs.count] == lhs
93
98
  end
94
99
  end
95
100
  end
@@ -5,7 +5,7 @@ require "active_record_doctor/detectors/base"
5
5
  module ActiveRecordDoctor
6
6
  module Detectors
7
7
  class IncorrectBooleanPresenceValidation < Base # :nodoc:
8
- @description = "detect persence (instead of inclusion) validators on boolean columns"
8
+ @description = "detect presence (instead of inclusion) validators on boolean columns"
9
9
  @config = {
10
10
  ignore_models: {
11
11
  description: "models whose validators should not be checked",
@@ -18,7 +18,8 @@ module ActiveRecordDoctor
18
18
 
19
19
  private
20
20
 
21
- def message(model:, association:, problem:, associated_models:, associated_models_type:)
21
+ def message(model:, association:, problem:, associated_models_type: nil,
22
+ table_name: nil, column_name: nil, associated_models: [])
22
23
  associated_models.sort!
23
24
 
24
25
  models_part =
@@ -36,6 +37,9 @@ module ActiveRecordDoctor
36
37
  case problem
37
38
  when :invalid_through
38
39
  "ensure #{model}.#{association} is configured correctly - #{associated_models[0]}.#{association} may be undefined"
40
+ when :destroy_async
41
+ "don't use `dependent: :destroy_async` on #{model}.#{association} or remove the foreign key from #{table_name}.#{column_name} - " \
42
+ "associated models will be deleted in the same transaction along with #{model}"
39
43
  when :suggest_destroy
40
44
  "use `dependent: :destroy` or similar on #{model}.#{association} - associated #{models_part} callbacks that are currently skipped"
41
45
  when :suggest_delete
@@ -88,29 +92,45 @@ module ActiveRecordDoctor
88
92
 
89
93
  deletable_models, destroyable_models = associated_models.partition { |klass| deletable?(klass) }
90
94
 
91
- if callback_action(association) == :invoke && destroyable_models.empty? && deletable_models.present?
92
- suggestion =
93
- case association.macro
94
- when :has_many then :suggest_delete_all
95
- when :has_one, :belongs_to then :suggest_delete
96
- else raise("unsupported association type #{association.macro}")
97
- end
98
-
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
- )
106
- elsif callback_action(association) == :skip && destroyable_models.present?
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
- )
95
+ case association.options[:dependent]
96
+ when :destroy_async
97
+ foreign_key = foreign_key(association.klass.table_name, model.table_name)
98
+ if foreign_key
99
+ problem!(
100
+ model: model.name,
101
+ association: association.name,
102
+ table_name: foreign_key.from_table,
103
+ column_name: foreign_key.column,
104
+ problem: :destroy_async
105
+ )
106
+ end
107
+ when :destroy
108
+ if destroyable_models.empty? && deletable_models.present?
109
+ suggestion =
110
+ case association.macro
111
+ when :has_many then :suggest_delete_all
112
+ when :has_one, :belongs_to then :suggest_delete
113
+ else raise("unsupported association type #{association.macro}")
114
+ end
115
+
116
+ problem!(
117
+ model: model.name,
118
+ association: association.name,
119
+ problem: suggestion,
120
+ associated_models: deletable_models.map(&:name),
121
+ associated_models_type: associated_models_type
122
+ )
123
+ end
124
+ when :delete, :delete_all
125
+ if destroyable_models.present?
126
+ problem!(
127
+ model: model.name,
128
+ association: association.name,
129
+ problem: :suggest_destroy,
130
+ associated_models: destroyable_models.map(&:name),
131
+ associated_models_type: associated_models_type
132
+ )
133
+ end
114
134
  end
115
135
  end
116
136
  end
@@ -127,13 +147,6 @@ module ActiveRecordDoctor
127
147
  end
128
148
  end
129
149
 
130
- def callback_action(reflection)
131
- case reflection.options[:dependent]
132
- when :delete, :delete_all then :skip
133
- when :destroy then :invoke
134
- end
135
- end
136
-
137
150
  def deletable?(model)
138
151
  !defines_destroy_callbacks?(model) &&
139
152
  dependent_models(model).all? do |dependent_model|
@@ -29,7 +29,7 @@ module ActiveRecordDoctor
29
29
  each_foreign_key(table) do |foreign_key|
30
30
  from_column = column(table, foreign_key.column)
31
31
 
32
- next if config(:ignore_columns).include?("#{table}.#{from_column.name}")
32
+ next if ignored?("#{table}.#{from_column.name}", config(:ignore_columns))
33
33
 
34
34
  to_table = foreign_key.to_table
35
35
  to_column = column(to_table, foreign_key.primary_key)
@@ -26,14 +26,14 @@ module ActiveRecordDoctor
26
26
  table_models = models.select(&:table_exists?).group_by(&:table_name)
27
27
 
28
28
  table_models.each do |table, models|
29
- next if config(:ignore_tables).include?(table)
29
+ next if ignored?(table, config(:ignore_tables))
30
30
 
31
31
  concrete_models = models.reject do |model|
32
32
  model.abstract_class? || sti_base_model?(model)
33
33
  end
34
34
 
35
35
  connection.columns(table).each do |column|
36
- next if config(:ignore_columns).include?("#{table}.#{column.name}")
36
+ next if ignored?("#{table}.#{column.name}", config(:ignore_columns))
37
37
  next if !column.null
38
38
  next if !concrete_models.all? { |model| non_null_needed?(model, column) }
39
39
  next if not_null_check_constraint_exists?(table, column)
@@ -22,9 +22,12 @@ module ActiveRecordDoctor
22
22
  def message(model:, table:, columns:, problem:)
23
23
  case problem
24
24
  when :validations
25
- "add a unique index on #{table}(#{columns.join(', ')}) - validating uniqueness in the model without an index can lead to duplicates"
25
+ "add a unique index on #{table}(#{columns.join(', ')}) - validating uniqueness in #{model.name} without an index can lead to duplicates"
26
+ when :case_insensitive_validations
27
+ "add a unique expression index on #{table}(#{columns.join(', ')}) - validating case-insensitive uniqueness in #{model.name} " \
28
+ "without an expression index can lead to duplicates (a regular unique index is not enough)"
26
29
  when :has_ones
27
- "add a unique index on #{table}(#{columns.first}) - using `has_one` in the #{model.name} model without an index can lead to duplicates"
30
+ "add a unique index on #{table}(#{columns.join(', ')}) - using `has_one` in #{model.name} without an index can lead to duplicates"
28
31
  end
29
32
  end
30
33
  # rubocop:enable Layout/LineLength
@@ -36,19 +39,44 @@ module ActiveRecordDoctor
36
39
 
37
40
  def validations_without_indexes
38
41
  each_model(except: config(:ignore_models), existing_tables_only: true) do |model|
39
- model.validators.each do |validator|
42
+ # Skip inherited validators from STI to prevent them
43
+ # from being reported multiple times on subclasses.
44
+ validators = model.validators - model.superclass.validators
45
+ validators.each do |validator|
40
46
  scope = Array(validator.options.fetch(:scope, []))
41
47
 
42
48
  next unless validator.is_a?(ActiveRecord::Validations::UniquenessValidator)
43
- next unless supported_validator?(validator)
49
+ next if conditional_validator?(validator)
50
+
51
+ # In Rails 6, default option values are no longer explicitly set on
52
+ # options so if the key is absent we must fetch the default value
53
+ # ourselves. case_sensitive is the default in 4.2+ so it's safe to
54
+ # put true literally.
55
+ case_sensitive = validator.options.fetch(:case_sensitive, true)
56
+
57
+ # ActiveRecord < 5.0 does not support expression indexes,
58
+ # so this will always be a false positive.
59
+ next if !case_sensitive && Utils.expression_indexes_unsupported?
44
60
 
45
61
  validator.attributes.each do |attribute|
46
62
  columns = resolve_attributes(model, scope + [attribute])
47
63
 
48
- next if unique_index?(model.table_name, columns)
49
64
  next if ignore_columns.include?("#{model.name}(#{columns.join(',')})")
50
65
 
51
- problem!(model: model, table: model.table_name, columns: columns, problem: :validations)
66
+ columns[-1] = "lower(#{columns[-1]})" unless case_sensitive
67
+
68
+ next if unique_index?(model.table_name, columns)
69
+
70
+ if case_sensitive
71
+ problem!(model: model, table: model.table_name, columns: columns, problem: :validations)
72
+ else
73
+ problem!(
74
+ model: model,
75
+ table: model.table_name,
76
+ columns: columns,
77
+ problem: :case_insensitive_validations
78
+ )
79
+ end
52
80
  end
53
81
  end
54
82
  end
@@ -57,29 +85,27 @@ module ActiveRecordDoctor
57
85
  def has_ones_without_indexes # rubocop:disable Naming/PredicateName
58
86
  each_model do |model|
59
87
  each_association(model, type: :has_one, has_scope: false, through: false) do |has_one|
60
- next if config(:ignore_models).include?(has_one.klass.name)
88
+ next if ignored?(has_one.klass.name, config(:ignore_models))
61
89
 
62
- foreign_key = has_one.foreign_key
63
- next if ignore_columns.include?(foreign_key.to_s)
90
+ columns =
91
+ if has_one.options[:as]
92
+ [has_one.type.to_s, has_one.foreign_key.to_s]
93
+ else
94
+ [has_one.foreign_key.to_s]
95
+ end
96
+ next if ignored?("#{has_one.klass.name}(#{columns.join(',')})", ignore_columns)
64
97
 
65
98
  table_name = has_one.klass.table_name
66
- next if unique_index?(table_name, [foreign_key])
99
+ next if unique_index?(table_name, columns)
100
+ next if Array(connection.primary_key(table_name)) == columns
67
101
 
68
- problem!(model: model, table: table_name, columns: [foreign_key], problem: :has_ones)
102
+ problem!(model: model, table: table_name, columns: columns, problem: :has_ones)
69
103
  end
70
104
  end
71
105
  end
72
106
 
73
- def supported_validator?(validator)
74
- validator.options[:if].nil? &&
75
- validator.options[:unless].nil? &&
76
- validator.options[:conditions].nil? &&
77
-
78
- # In Rails 6, default option values are no longer explicitly set on
79
- # options so if the key is absent we must fetch the default value
80
- # ourselves. case_sensitive is the default in 4.2+ so it's safe to
81
- # put true literally.
82
- validator.options.fetch(:case_sensitive, true)
107
+ def conditional_validator?(validator)
108
+ (validator.options.keys & [:if, :unless, :conditions]).present?
83
109
  end
84
110
 
85
111
  def resolve_attributes(model, attributes)
@@ -99,17 +125,41 @@ module ActiveRecordDoctor
99
125
  def unique_index?(table_name, columns, scope = nil)
100
126
  columns = (Array(scope) + columns).map(&:to_s)
101
127
  indexes(table_name).any? do |index|
128
+ index_columns =
129
+ # For expression indexes, Active Record returns columns as string.
130
+ if index.columns.is_a?(String)
131
+ extract_index_columns(index.columns)
132
+ else
133
+ index.columns
134
+ end
135
+
102
136
  index.unique &&
103
137
  index.where.nil? &&
104
- (Array(index.columns) - columns).empty?
138
+ (index_columns - columns).empty?
105
139
  end
106
140
  end
107
141
 
108
142
  def ignore_columns
109
143
  @ignore_columns ||= config(:ignore_columns).map do |column|
110
- column.gsub(" ", "")
144
+ if column.is_a?(String)
145
+ column.gsub(" ", "")
146
+ else
147
+ column
148
+ end
111
149
  end
112
150
  end
151
+
152
+ def extract_index_columns(columns)
153
+ columns
154
+ .split(",")
155
+ .map(&:strip)
156
+ .map do |column|
157
+ column.gsub(/lower\(/i, "lower(")
158
+ .gsub(/\((\w+)\)::\w+/, '\1') # (email)::string
159
+ .gsub(/([`'"])(\w+)\1/, '\2') # quoted identifiers
160
+ .gsub(/\A\((.+)\)\z/, '\1') # remove surrounding braces from MySQL
161
+ end
162
+ end
113
163
  end
114
164
  end
115
165
  end
@@ -23,7 +23,7 @@ module ActiveRecordDoctor
23
23
  each_table(except: config(:ignore_tables)) do |table|
24
24
  column = primary_key(table)
25
25
  next if column.nil?
26
- next if bigint?(column) || uuid?(column)
26
+ next if !integer?(column) || bigint?(column)
27
27
 
28
28
  problem!(table: table, column: column.name)
29
29
  end
@@ -37,8 +37,8 @@ module ActiveRecordDoctor
37
37
  end
38
38
  end
39
39
 
40
- def uuid?(column)
41
- column.sql_type == "uuid"
40
+ def integer?(column)
41
+ column.type == :integer
42
42
  end
43
43
  end
44
44
  end