active_record_doctor 1.11.0 → 1.13.0

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