active_record_doctor 1.11.0 → 1.12.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fef82b1493488683e11bc72273e142479cb47234651a8aa772a83121960f9309
4
- data.tar.gz: c99afc25d8307ff0814e0459790f3d7fcf0b015d9f37f118870a9a1cfa2cf460
3
+ metadata.gz: d848e296c39f7994781bd645261eb45235450be36058fbdf7368ec6358ebeba5
4
+ data.tar.gz: a63cbbfe5b43fb3bf6daeaba24a6298073643d5b5c96696c27c084106fe54594
5
5
  SHA512:
6
- metadata.gz: ab75ff192c31cfc10cbd5c4106ca0033cc4d408c1e4977a417f76c53f2a2e73e37e5706162cd4807c700808e171756ddda022af6658b60f460d78109f9bea2ee
7
- data.tar.gz: 075625a08ac5f192a68ed6c99409a4acf85cadb77ec117d6933186c2bf4d07e5aaf08928e9f30d5dd96b263e8e530a4a4da848ef5a4dfc82d5dae0df2475f7d2
6
+ metadata.gz: 47411eea54d4c21329459dc7490090a535c53e9f3be912268ceed2f6e2588246cb07c9b90e758559d9b0c08de319f3dc6a4735c79511bc30c2ec7b058104c08e
7
+ data.tar.gz: 9596cb799a4346b3ec60562876750990b486efc7daebfda0834b028bf2db9075f04bf6b028185df6076ede3a7227cb9755a6ac9e8db609431d063261f3c0732d
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)
@@ -158,7 +158,7 @@ three-step process:
158
158
  ```
159
159
 
160
160
  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
161
+ as a column can look like a foreign key (i.e. ending with `_id`) without being
162
162
  one.
163
163
 
164
164
  3. Generate the migrations
@@ -209,7 +209,7 @@ To discover such indexes automatically just follow these steps:
209
209
 
210
210
  3. Create a migration to drop the indexes.
211
211
 
212
- The indexes aren't dropped automatically because there's usually just a few of
212
+ The indexes aren't dropped automatically because there are usually just a few of
213
213
  them and it's a good idea to double-check that you won't drop something
214
214
  necessary.
215
215
 
@@ -265,11 +265,11 @@ Supported configuration options:
265
265
  If `users.profile_id` references a row in `profiles` then this can be expressed
266
266
  at the database level with a foreign key constraint. It _forces_
267
267
  `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
268
+ that in many legacy Rails apps, the constraint isn't enforced at the database
269
269
  level.
270
270
 
271
271
  `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
272
+ from a foreign key constraint (a future version will generate a migration that
273
273
  add the constraint; for now, it's your job). You can obtain the list of foreign
274
274
  keys with the following command:
275
275
 
@@ -307,7 +307,7 @@ before they hit production.
307
307
  * Rails 5+ and _any_ database or
308
308
  * Rails 4.2 with PostgreSQL.
309
309
 
310
- The only think you need to do is run:
310
+ The only thing you need to do is run:
311
311
 
312
312
  ```
313
313
  bundle exec rake active_record_doctor:undefined_table_references
@@ -320,7 +320,7 @@ this:
320
320
  Contract references a non-existent table or view named contract_records
321
321
  ```
322
322
 
323
- On top of that `rake` will exit with status code of 1. This allows you to use
323
+ On top of that `rake` will exit with a status code of 1. This allows you to use
324
324
  this check as part of your Continuous Integration pipeline.
325
325
 
326
326
  Supported configuration options:
@@ -333,7 +333,7 @@ Supported configuration options:
333
333
 
334
334
  Model-level uniqueness validations and `has_one` associations should be backed
335
335
  by a database index in order to be robust. Otherwise you risk inserting
336
- duplicate values under heavy load.
336
+ duplicate values under a heavy load.
337
337
 
338
338
  In order to detect such validations run:
339
339
 
@@ -475,7 +475,7 @@ Supported configuration options:
475
475
 
476
476
  - `enabled` - set to `false` to disable the detector altogether
477
477
  - `ignore_models` - models whose validators should not be checked.
478
- - `ignore_columns` - attributes, written as Model.attribute, whose validators
478
+ - `ignore_attributes` - attributes, written as Model.attribute, whose validators
479
479
  should not be checked.
480
480
 
481
481
  ### Detecting Incorrect `dependent` Option on Associations
@@ -489,7 +489,7 @@ This can lead to two types of errors:
489
489
  - Using `delete_all` when dependent models define callbacks - they will NOT be
490
490
  invoked.
491
491
  - Using `destroy` when dependent models define no callbacks - dependent models
492
- will be loaded one-by-one with no reason
492
+ will be loaded one by one with no reason
493
493
 
494
494
  In order to detect associations affected by the two aforementioned problems run
495
495
  the following command:
@@ -530,8 +530,8 @@ The output of the command looks like this:
530
530
  change the type of companies.id to bigint
531
531
  ```
532
532
 
533
- The above means `comanies.id` should be migrated to a wider integer type. An
534
- example migration to accomplish this looks likes this:
533
+ The above means `companies.id` should be migrated to a wider integer type. An
534
+ example migration to accomplish this looks like this:
535
535
 
536
536
  ```ruby
537
537
  class ChangeCompaniesPrimaryKeyType < ActiveRecord::Migration[5.1]
@@ -565,7 +565,7 @@ bundle exec rake active_record_doctor:mismatched_foreign_key_type
565
565
  The output of the command looks like this:
566
566
 
567
567
  ```
568
- companies.user_id references a column of different type - foreign keys should be of the same type as the referenced column
568
+ companies.user_id references a column of a different type - foreign keys should be of the same type as the referenced column
569
569
  ```
570
570
 
571
571
  Supported configuration options:
@@ -584,7 +584,7 @@ combinations of Ruby and Rails versions. Specifically:
584
584
  supported by `active_record_doctor`.
585
585
  2. If a Ruby version is compatible with a supported Rails version then it's
586
586
  also supported by `active_record_doctor`.
587
- 3. Only most recent teeny Ruby versions and patch Rails versions are supported.
587
+ 3. Only the most recent teeny Ruby versions and patch Rails versions are supported.
588
588
 
589
589
  ## Author
590
590
 
@@ -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,10 +164,6 @@ 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|
@@ -281,14 +277,29 @@ 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 except.include?(data_source)
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
@@ -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
@@ -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,40 @@ 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!(model: model.name, association: association.name,
100
+ table_name: foreign_key.from_table, column_name: foreign_key.column, problem: :destroy_async)
101
+ end
102
+ when :destroy
103
+ if destroyable_models.empty? && deletable_models.present?
104
+ suggestion =
105
+ case association.macro
106
+ when :has_many then :suggest_delete_all
107
+ when :has_one, :belongs_to then :suggest_delete
108
+ else raise("unsupported association type #{association.macro}")
109
+ end
110
+
111
+ problem!(
112
+ model: model.name,
113
+ association: association.name,
114
+ problem: suggestion,
115
+ associated_models: deletable_models.map(&:name),
116
+ associated_models_type: associated_models_type
117
+ )
118
+ end
119
+ when :delete, :delete_all
120
+ if destroyable_models.present?
121
+ problem!(
122
+ model: model.name,
123
+ association: association.name,
124
+ problem: :suggest_destroy,
125
+ associated_models: destroyable_models.map(&:name),
126
+ associated_models_type: associated_models_type
127
+ )
128
+ end
114
129
  end
115
130
  end
116
131
  end
@@ -127,13 +142,6 @@ module ActiveRecordDoctor
127
142
  end
128
143
  end
129
144
 
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
145
  def deletable?(model)
138
146
  !defines_destroy_callbacks?(model) &&
139
147
  dependent_models(model).all? do |dependent_model|
@@ -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,40 @@ 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!(model: model, table: model.table_name, columns: columns,
74
+ problem: :case_insensitive_validations)
75
+ end
52
76
  end
53
77
  end
54
78
  end
@@ -59,27 +83,24 @@ module ActiveRecordDoctor
59
83
  each_association(model, type: :has_one, has_scope: false, through: false) do |has_one|
60
84
  next if config(:ignore_models).include?(has_one.klass.name)
61
85
 
62
- foreign_key = has_one.foreign_key
63
- next if ignore_columns.include?(foreign_key.to_s)
86
+ columns =
87
+ if has_one.options[:as]
88
+ [has_one.type.to_s, has_one.foreign_key.to_s]
89
+ else
90
+ [has_one.foreign_key.to_s]
91
+ end
92
+ next if ignore_columns.include?("#{model.name}(#{columns.join(',')})")
64
93
 
65
94
  table_name = has_one.klass.table_name
66
- next if unique_index?(table_name, [foreign_key])
95
+ next if unique_index?(table_name, columns)
67
96
 
68
- problem!(model: model, table: table_name, columns: [foreign_key], problem: :has_ones)
97
+ problem!(model: model, table: table_name, columns: columns, problem: :has_ones)
69
98
  end
70
99
  end
71
100
  end
72
101
 
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)
102
+ def conditional_validator?(validator)
103
+ (validator.options.keys & [:if, :unless, :conditions]).present?
83
104
  end
84
105
 
85
106
  def resolve_attributes(model, attributes)
@@ -99,9 +120,17 @@ module ActiveRecordDoctor
99
120
  def unique_index?(table_name, columns, scope = nil)
100
121
  columns = (Array(scope) + columns).map(&:to_s)
101
122
  indexes(table_name).any? do |index|
123
+ index_columns =
124
+ # For expression indexes, Active Record returns columns as string.
125
+ if index.columns.is_a?(String)
126
+ extract_index_columns(index.columns)
127
+ else
128
+ index.columns
129
+ end
130
+
102
131
  index.unique &&
103
132
  index.where.nil? &&
104
- (Array(index.columns) - columns).empty?
133
+ (index_columns - columns).empty?
105
134
  end
106
135
  end
107
136
 
@@ -110,6 +139,18 @@ module ActiveRecordDoctor
110
139
  column.gsub(" ", "")
111
140
  end
112
141
  end
142
+
143
+ def extract_index_columns(columns)
144
+ columns
145
+ .split(",")
146
+ .map(&:strip)
147
+ .map do |column|
148
+ column.gsub(/lower\(/i, "lower(")
149
+ .gsub(/\((\w+)\)::\w+/, '\1') # (email)::string
150
+ .gsub(/([`'"])(\w+)\1/, '\2') # quoted identifiers
151
+ .gsub(/\A\((.+)\)\z/, '\1') # remove surrounding braces from MySQL
152
+ end
153
+ end
113
154
  end
114
155
  end
115
156
  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
@@ -18,28 +18,43 @@ module ActiveRecordDoctor
18
18
 
19
19
  private
20
20
 
21
- def message(table:, column:)
21
+ def message(table:, columns:)
22
22
  # rubocop:disable Layout/LineLength
23
- "add an index on #{table}.#{column} - foreign keys are often used in database lookups and should be indexed for performance reasons"
23
+ "add an index on #{table}(#{columns.join(', ')}) - foreign keys are often used in database lookups and should be indexed for performance reasons"
24
24
  # rubocop:enable Layout/LineLength
25
25
  end
26
26
 
27
27
  def detect
28
28
  each_table(except: config(:ignore_tables)) do |table|
29
29
  each_column(table, except: config(:ignore_columns)) do |column|
30
- next unless foreign_key?(column)
30
+ next unless named_like_foreign_key?(column) || foreign_key?(table, column)
31
31
  next if indexed?(table, column)
32
32
  next if indexed_as_polymorphic?(table, column)
33
33
 
34
- problem!(table: table, column: column.name)
34
+ type_column_name = type_column_name(column)
35
+
36
+ columns =
37
+ if column_exists?(table, type_column_name)
38
+ [type_column_name, column.name]
39
+ else
40
+ [column.name]
41
+ end
42
+
43
+ problem!(table: table, columns: columns)
35
44
  end
36
45
  end
37
46
  end
38
47
 
39
- def foreign_key?(column)
48
+ def named_like_foreign_key?(column)
40
49
  column.name.end_with?("_id")
41
50
  end
42
51
 
52
+ def foreign_key?(table, column)
53
+ connection.foreign_keys(table).any? do |foreign_key|
54
+ foreign_key.column == column.name
55
+ end
56
+ end
57
+
43
58
  def indexed?(table, column)
44
59
  connection.indexes(table).any? do |index|
45
60
  index.columns.first == column.name
@@ -47,9 +62,20 @@ module ActiveRecordDoctor
47
62
  end
48
63
 
49
64
  def indexed_as_polymorphic?(table, column)
50
- type_column_name = column.name.sub(/_id\Z/, "_type")
51
65
  connection.indexes(table).any? do |index|
52
- index.columns == [type_column_name, column.name]
66
+ index.columns[0, 2] == [type_column_name(column), column.name]
67
+ end
68
+ end
69
+
70
+ def column_exists?(table, column_name)
71
+ connection.columns(table).any? { |column| column.name == column_name }
72
+ end
73
+
74
+ def type_column_name(column)
75
+ if column.name.end_with?("_id")
76
+ column.name.sub(/_id\Z/, "_type")
77
+ else
78
+ "#{column.name}_type"
53
79
  end
54
80
  end
55
81
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDoctor
4
+ module Utils # :nodoc:
5
+ class << self
6
+ def postgresql?(connection = ActiveRecord::Base.connection)
7
+ ["PostgreSQL", "PostGIS"].include?(connection.adapter_name)
8
+ end
9
+
10
+ def mysql?(connection = ActiveRecord::Base.connection)
11
+ connection.adapter_name == "Mysql2"
12
+ end
13
+
14
+ def expression_indexes_unsupported?(connection = ActiveRecord::Base.connection)
15
+ (ActiveRecord::VERSION::STRING < "5.0") ||
16
+ # Active Record < 6 is unable to correctly parse expression indexes for MySQL.
17
+ (mysql?(connection) && ActiveRecord::VERSION::STRING < "6.0")
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordDoctor
4
- VERSION = "1.11.0"
4
+ VERSION = "1.12.0"
5
5
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_record_doctor/railtie" if defined?(Rails) && defined?(Rails::Railtie)
4
+ require "active_record_doctor/utils"
4
5
  require "active_record_doctor/logger"
5
6
  require "active_record_doctor/logger/dummy"
6
7
  require "active_record_doctor/logger/hierarchical"
@@ -25,6 +26,7 @@ require "active_record_doctor/runner"
25
26
  require "active_record_doctor/version"
26
27
  require "active_record_doctor/config"
27
28
  require "active_record_doctor/config/loader"
29
+ require "active_record_doctor/rake/task"
28
30
 
29
31
  module ActiveRecordDoctor # :nodoc:
30
32
  end
@@ -10,16 +10,16 @@ module ActiveRecordDoctor
10
10
  migration_descriptions = read_migration_descriptions(path)
11
11
  now = Time.now
12
12
 
13
- migration_descriptions.each_with_index do |(table, columns), index|
13
+ migration_descriptions.each_with_index do |(table, indexes), index|
14
14
  timestamp = (now + index).strftime("%Y%m%d%H%M%S")
15
15
  file_name = "db/migrate/#{timestamp}_index_foreign_keys_in_#{table}.rb"
16
- create_file(file_name, content(table, columns).tap { |x| puts x })
16
+ create_file(file_name, content(table, indexes).tap { |x| puts x })
17
17
  end
18
18
  end
19
19
 
20
20
  private
21
21
 
22
- INPUT_LINE = /^add an index on (\w+)\.(\w+) - .*$/.freeze
22
+ INPUT_LINE = /^add an index on (\w+)\((.+)\) - .*$/.freeze
23
23
  private_constant :INPUT_LINE
24
24
 
25
25
  def read_migration_descriptions(path)
@@ -34,41 +34,41 @@ module ActiveRecordDoctor
34
34
  end
35
35
 
36
36
  table = match[1]
37
- column = match[2]
37
+ columns = match[2].split(",").map(&:strip)
38
38
 
39
- tables_to_columns[table] << column
39
+ tables_to_columns[table] << columns
40
40
  end
41
41
 
42
42
  tables_to_columns
43
43
  end
44
44
 
45
- def content(table, columns)
45
+ def content(table, indexes)
46
46
  # In order to properly indent the resulting code, we must disable the
47
47
  # rubocop rule below.
48
48
 
49
49
  <<MIGRATION
50
50
  class IndexForeignKeysIn#{table.camelize} < ActiveRecord::Migration#{migration_version}
51
51
  def change
52
- #{add_indexes(table, columns)}
52
+ #{add_indexes(table, indexes)}
53
53
  end
54
54
  end
55
55
  MIGRATION
56
56
  end
57
57
 
58
- def add_indexes(table, columns)
59
- columns.map do |column|
60
- add_index(table, column)
58
+ def add_indexes(table, indexes)
59
+ indexes.map do |columns|
60
+ add_index(table, columns)
61
61
  end.join("\n")
62
62
  end
63
63
 
64
- def add_index(table, column)
64
+ def add_index(table, columns)
65
65
  connection = ActiveRecord::Base.connection
66
66
 
67
- index_name = connection.index_name(table, column)
67
+ index_name = connection.index_name(table, columns)
68
68
  if index_name.size > connection.index_name_length
69
- " add_index :#{table}, :#{column}, name: '#{index_name.first(connection.index_name_length)}'"
69
+ " add_index :#{table}, #{columns.inspect}, name: '#{index_name.first(connection.index_name_length)}'"
70
70
  else
71
- " add_index :#{table}, :#{column}"
71
+ " add_index :#{table}, #{columns.inspect}"
72
72
  end
73
73
  end
74
74
 
@@ -7,7 +7,7 @@ class ActiveRecordDoctor::Detectors::ExtraneousIndexesTest < Minitest::Test
7
7
  end
8
8
 
9
9
  assert_problems(<<OUTPUT)
10
- remove index_users_on_id - coincides with the primary key on the table
10
+ remove index_users_on_id from users - coincides with the primary key on the table
11
11
  OUTPUT
12
12
  end
13
13
 
@@ -28,7 +28,7 @@ OUTPUT
28
28
  end
29
29
 
30
30
  assert_problems(<<OUTPUT)
31
- remove index_profiles_on_user_id - coincides with the primary key on the table
31
+ remove index_profiles_on_user_id from profiles - coincides with the primary key on the table
32
32
  OUTPUT
33
33
  end
34
34
 
@@ -42,7 +42,7 @@ OUTPUT
42
42
  ActiveRecord::Base.connection.add_index :users, :email, name: "index_users_on_email"
43
43
 
44
44
  assert_problems(<<OUTPUT)
45
- remove index_users_on_email - can be replaced by unique_index_on_users_email
45
+ remove the index index_users_on_email from the table users - queries should be able to use the following index instead: unique_index_on_users_email
46
46
  OUTPUT
47
47
  end
48
48
 
@@ -59,7 +59,7 @@ OUTPUT
59
59
  end
60
60
 
61
61
  assert_problems(<<OUTPUT)
62
- remove index_users_on_last_name - can be replaced by index_users_on_last_name_and_first_name_and_email or unique_index_on_users_last_name_and_first_name
62
+ remove the index index_users_on_last_name from the table users - queries should be able to use the following indices instead: index_users_on_last_name_and_first_name_and_email or unique_index_on_users_last_name_and_first_name
63
63
  OUTPUT
64
64
  end
65
65
 
@@ -78,7 +78,7 @@ OUTPUT
78
78
  ActiveRecord::Base.connection.add_index :users, [:last_name, :first_name]
79
79
 
80
80
  assert_problems(<<OUTPUT)
81
- remove index_users_on_last_name_and_first_name - can be replaced by index_users_on_last_name_and_first_name_and_email or unique_index_on_users_last_name_and_first_name
81
+ remove the index index_users_on_last_name_and_first_name from the table users - queries should be able to use the following indices instead: index_users_on_last_name_and_first_name_and_email or unique_index_on_users_last_name_and_first_name
82
82
  OUTPUT
83
83
  end
84
84
 
@@ -91,10 +91,36 @@ OUTPUT
91
91
  end
92
92
 
93
93
  assert_problems(<<OUTPUT)
94
- remove index_users_on_last_name_and_first_name - can be replaced by index_users_on_first_name
94
+ remove the index index_users_on_last_name_and_first_name from the table users - queries should be able to use the following index instead: index_users_on_first_name
95
95
  OUTPUT
96
96
  end
97
97
 
98
+ def test_expression_index_not_covered_by_multicolumn_index
99
+ skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
100
+
101
+ create_table(:users) do |t|
102
+ t.string :first_name
103
+ t.string :email
104
+ t.index "(lower(email))"
105
+ t.index [:first_name, :email]
106
+ end
107
+
108
+ refute_problems
109
+ end
110
+
111
+ def test_unique_expression_index_not_covered_by_unique_multicolumn_index
112
+ skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
113
+
114
+ create_table(:users) do |t|
115
+ t.string :first_name
116
+ t.string :email
117
+ t.index "(lower(email))", unique: true
118
+ t.index [:first_name, :email], unique: true
119
+ end
120
+
121
+ refute_problems
122
+ end
123
+
98
124
  def test_not_covered_by_different_index_type
99
125
  create_table(:users) do |t|
100
126
  t.string :first_name
@@ -139,6 +165,33 @@ OUTPUT
139
165
  refute_problems
140
166
  end
141
167
 
168
+ def test_single_column_covered_by_multi_column_on_materialized_view_is_duplicate
169
+ skip("Only PostgreSQL supports materialized views") unless postgresql?
170
+
171
+ begin
172
+ create_table(:users) do |t|
173
+ t.string :first_name
174
+ t.string :last_name
175
+ t.integer :age
176
+ end
177
+
178
+ connection = ActiveRecord::Base.connection
179
+ connection.execute(<<-SQL)
180
+ CREATE MATERIALIZED VIEW user_initials AS
181
+ SELECT first_name, last_name FROM users
182
+ SQL
183
+
184
+ connection.add_index(:user_initials, [:last_name, :first_name])
185
+ connection.add_index(:user_initials, :last_name)
186
+
187
+ assert_problems(<<OUTPUT)
188
+ remove the index index_user_initials_on_last_name from the table user_initials - queries should be able to use the following index instead: index_user_initials_on_last_name_and_first_name
189
+ OUTPUT
190
+ ensure
191
+ connection.execute("DROP MATERIALIZED VIEW user_initials")
192
+ end
193
+ end
194
+
142
195
  def test_config_ignore_tables
143
196
  # The detector recognizes two kinds of errors and both must take
144
197
  # ignore_tables into account. We trigger those errors by indexing the
@@ -405,6 +405,45 @@ class ActiveRecordDoctor::Detectors::IncorrectDependentOptionTest < Minitest::Te
405
405
  OUTPUT
406
406
  end
407
407
 
408
+ def test_destroy_async_and_foreign_key_exists
409
+ skip("ActiveRecord < 6.1 doesn't support :destroy_async") if ActiveRecord::VERSION::STRING < "6.1"
410
+
411
+ create_table(:companies) do
412
+ end.define_model do
413
+ # We need an ActiveJob job defined to appease the ActiveRecord
414
+ class_attribute :destroy_association_async_job, default: Class.new
415
+
416
+ has_many :users, dependent: :destroy_async
417
+ end
418
+
419
+ create_table(:users) do |t|
420
+ t.references :company, foreign_key: true
421
+ end.define_model
422
+
423
+ assert_problems(<<~OUTPUT)
424
+ don't use `dependent: :destroy_async` on TransientRecord::Models::Company.users or remove the foreign key from users.company_id - \
425
+ associated models will be deleted in the same transaction along with TransientRecord::Models::Company
426
+ OUTPUT
427
+ end
428
+
429
+ def test_destroy_async_and_no_foreign_key
430
+ skip("ActiveRecord < 6.1 doesn't support :destroy_async") if ActiveRecord::VERSION::STRING < "6.1"
431
+
432
+ create_table(:companies) do
433
+ end.define_model do
434
+ # We need an ActiveJob job defined to appease the ActiveRecord
435
+ class_attribute :destroy_association_async_job, default: Class.new
436
+
437
+ has_many :users, dependent: :destroy_async
438
+ end
439
+
440
+ create_table(:users) do |t|
441
+ t.references :company, foreign_key: false
442
+ end.define_model
443
+
444
+ refute_problems
445
+ end
446
+
408
447
  def test_config_ignore_models
409
448
  create_table(:companies) do
410
449
  end.define_model do
@@ -10,7 +10,7 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
10
10
  end
11
11
 
12
12
  assert_problems(<<~OUTPUT)
13
- add a unique index on users(email) - validating uniqueness in the model without an index can lead to duplicates
13
+ add a unique index on users(email) - validating uniqueness in TransientRecord::Models::User without an index can lead to duplicates
14
14
  OUTPUT
15
15
  end
16
16
 
@@ -39,8 +39,8 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
39
39
  end
40
40
 
41
41
  assert_problems(<<~OUTPUT)
42
- add a unique index on users(email) - validating uniqueness in the model without an index can lead to duplicates
43
- add a unique index on users(ref_token) - validating uniqueness in the model without an index can lead to duplicates
42
+ add a unique index on users(email) - validating uniqueness in TransientRecord::Models::User without an index can lead to duplicates
43
+ add a unique index on users(ref_token) - validating uniqueness in TransientRecord::Models::User without an index can lead to duplicates
44
44
  OUTPUT
45
45
  end
46
46
 
@@ -55,6 +55,25 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
55
55
  refute_problems
56
56
  end
57
57
 
58
+ def test_missing_unique_index_reported_only_on_base_class
59
+ create_table(:users) do |t|
60
+ t.string :type
61
+ t.string :email
62
+ t.string :name
63
+ end.define_model do
64
+ validates :email, uniqueness: true
65
+ end
66
+
67
+ define_model(:Client, TransientRecord::Models::User) do
68
+ validates :name, uniqueness: true
69
+ end
70
+
71
+ assert_problems(<<~OUTPUT)
72
+ add a unique index on users(email) - validating uniqueness in TransientRecord::Models::User without an index can lead to duplicates
73
+ add a unique index on users(name) - validating uniqueness in TransientRecord::Models::Client without an index can lead to duplicates
74
+ OUTPUT
75
+ end
76
+
58
77
  def test_present_partial_unique_index
59
78
  skip("MySQL doesn't support partial indexes") if mysql?
60
79
 
@@ -67,7 +86,7 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
67
86
  end
68
87
 
69
88
  assert_problems(<<~OUTPUT)
70
- add a unique index on users(email) - validating uniqueness in the model without an index can lead to duplicates
89
+ add a unique index on users(email) - validating uniqueness in TransientRecord::Models::User without an index can lead to duplicates
71
90
  OUTPUT
72
91
  end
73
92
 
@@ -82,7 +101,7 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
82
101
  end
83
102
 
84
103
  assert_problems(<<~OUTPUT)
85
- add a unique index on users(company_id, department_id, email) - validating uniqueness in the model without an index can lead to duplicates
104
+ add a unique index on users(company_id, department_id, email) - validating uniqueness in TransientRecord::Models::User without an index can lead to duplicates
86
105
  OUTPUT
87
106
  end
88
107
 
@@ -121,7 +140,7 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
121
140
  end
122
141
 
123
142
  assert_problems(<<~OUTPUT)
124
- add a unique index on users(account_id) - validating uniqueness in the model without an index can lead to duplicates
143
+ add a unique index on users(account_id) - validating uniqueness in TransientRecord::Models::User without an index can lead to duplicates
125
144
  OUTPUT
126
145
  end
127
146
 
@@ -148,7 +167,7 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
148
167
  end
149
168
 
150
169
  assert_problems(<<~OUTPUT)
151
- add a unique index on comments(commentable_type, commentable_id, title) - validating uniqueness in the model without an index can lead to duplicates
170
+ add a unique index on comments(commentable_type, commentable_id, title) - validating uniqueness in TransientRecord::Models::Comment without an index can lead to duplicates
152
171
  OUTPUT
153
172
  end
154
173
 
@@ -179,12 +198,112 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
179
198
  refute_problems
180
199
  end
181
200
 
182
- def test_conditions_is_skipped
183
- assert_skipped(conditions: -> { where.not(email: nil) })
201
+ def test_case_insensitive_unique_index_exists
202
+ skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
203
+
204
+ create_table(:users) do |t|
205
+ t.string :email
206
+
207
+ t.index :email, unique: true
208
+ end.define_model do
209
+ validates :email, uniqueness: { case_sensitive: false }
210
+ end
211
+
212
+ assert_problems(<<~OUTPUT)
213
+ add a unique expression index on users(lower(email)) - validating case-insensitive uniqueness in TransientRecord::Models::User without an expression index can lead to duplicates (a regular unique index is not enough)
214
+ OUTPUT
215
+ end
216
+
217
+ def test_case_insensitive_non_unique_lower_index_exists
218
+ skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
219
+
220
+ create_table(:users) do |t|
221
+ t.string :email
222
+ end.define_model do
223
+ validates :email, uniqueness: { case_sensitive: false }
224
+ end
225
+
226
+ # ActiveRecord < 5 does not support expression indexes.
227
+ ActiveRecord::Base.connection.execute(<<-SQL)
228
+ CREATE INDEX index_users_on_lower_email ON users ((lower(email)))
229
+ SQL
230
+
231
+ assert_problems(<<~OUTPUT)
232
+ add a unique expression index on users(lower(email)) - validating case-insensitive uniqueness in TransientRecord::Models::User without an expression index can lead to duplicates (a regular unique index is not enough)
233
+ OUTPUT
234
+ end
235
+
236
+ def test_case_insensitive_unique_lower_index_exists
237
+ skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
238
+
239
+ create_table(:users) do |t|
240
+ t.string :email
241
+ end.define_model do
242
+ validates :email, uniqueness: { case_sensitive: false }
243
+ end
244
+
245
+ # ActiveRecord < 5 does not support expression indexes.
246
+ ActiveRecord::Base.connection.execute(<<-SQL)
247
+ CREATE UNIQUE INDEX index_users_on_lower_email ON users ((lower(email)))
248
+ SQL
249
+
250
+ refute_problems
251
+ end
252
+
253
+ def test_case_insensitive_compound_unique_index_exists
254
+ skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
255
+
256
+ create_table(:users) do |t|
257
+ t.string :email
258
+ t.integer :organization_id
259
+ t.index [:email, :organization_id], unique: true
260
+ end.define_model do
261
+ validates :email, uniqueness: { scope: :organization_id, case_sensitive: false }
262
+ end
263
+
264
+ assert_problems(<<~OUTPUT)
265
+ add a unique expression index on users(organization_id, lower(email)) - validating case-insensitive uniqueness in TransientRecord::Models::User without an expression index can lead to duplicates (a regular unique index is not enough)
266
+ OUTPUT
267
+ end
268
+
269
+ def test_case_insensitive_compound_non_unique_lower_index_exists
270
+ skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
271
+
272
+ create_table(:users) do |t|
273
+ t.string :email
274
+ t.integer :organization_id
275
+ end.define_model do
276
+ validates :email, uniqueness: { scope: :organization_id, case_sensitive: false }
277
+ end
278
+
279
+ ActiveRecord::Base.connection.execute(<<-SQL)
280
+ CREATE INDEX index_users_on_lower_email_and_organization_id ON users ((lower(email)), organization_id)
281
+ SQL
282
+
283
+ assert_problems(<<~OUTPUT)
284
+ add a unique expression index on users(organization_id, lower(email)) - validating case-insensitive uniqueness in TransientRecord::Models::User without an expression index can lead to duplicates (a regular unique index is not enough)
285
+ OUTPUT
286
+ end
287
+
288
+ def test_case_insensitive_compound_unique_lower_index_exists
289
+ skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
290
+
291
+ create_table(:users) do |t|
292
+ t.string :email
293
+ t.integer :organization_id
294
+ end.define_model do
295
+ validates :email, uniqueness: { scope: :organization_id, case_sensitive: false }
296
+ end
297
+
298
+ ActiveRecord::Base.connection.execute(<<-SQL)
299
+ CREATE UNIQUE INDEX index_users_on_lower_email_and_organization_id ON users ((lower(email)), organization_id)
300
+ SQL
301
+
302
+ refute_problems
184
303
  end
185
304
 
186
- def test_case_insensitive_is_skipped
187
- assert_skipped(case_sensitive: false)
305
+ def test_conditions_is_skipped
306
+ assert_skipped(conditions: -> { where.not(email: nil) })
188
307
  end
189
308
 
190
309
  def test_if_is_skipped
@@ -226,8 +345,8 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
226
345
  end
227
346
 
228
347
  assert_problems(<<~OUTPUT)
229
- add a unique index on accounts(user_id) - using `has_one` in the TransientRecord::Models::User model without an index can lead to duplicates
230
- add a unique index on account_histories(account_id) - using `has_one` in the TransientRecord::Models::Account model without an index can lead to duplicates
348
+ add a unique index on accounts(user_id) - using `has_one` in TransientRecord::Models::User without an index can lead to duplicates
349
+ add a unique index on account_histories(account_id) - using `has_one` in TransientRecord::Models::Account without an index can lead to duplicates
231
350
  OUTPUT
232
351
  end
233
352
 
@@ -244,6 +363,24 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
244
363
  refute_problems
245
364
  end
246
365
 
366
+ def test_missing_has_one_unique_index_reported_only_on_base_class
367
+ create_table(:users) do |t|
368
+ t.string :type
369
+ end.define_model do
370
+ has_one :account, class_name: "TransientRecord::Models::Account"
371
+ end
372
+
373
+ define_model(:Client, TransientRecord::Models::User)
374
+
375
+ create_table(:accounts) do |t|
376
+ t.integer :user_id
377
+ end.define_model
378
+
379
+ assert_problems(<<~OUTPUT)
380
+ add a unique index on accounts(user_id) - using `has_one` in TransientRecord::Models::User without an index can lead to duplicates
381
+ OUTPUT
382
+ end
383
+
247
384
  def test_has_one_with_index
248
385
  create_table(:users)
249
386
  .define_model do
@@ -257,6 +394,38 @@ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
257
394
  refute_problems
258
395
  end
259
396
 
397
+ def test_polymorphic_has_one_without_index
398
+ create_table(:users)
399
+ .define_model do
400
+ has_one :account, as: :accountable
401
+ end
402
+
403
+ create_table(:accounts) do |t|
404
+ t.belongs_to :accountable, polymorphic: true, index: false
405
+ end.define_model do
406
+ belongs_to :accountable, polymorphic: true
407
+ end
408
+
409
+ assert_problems(<<~OUTPUT)
410
+ add a unique index on accounts(accountable_type, accountable_id) - using `has_one` in TransientRecord::Models::User without an index can lead to duplicates
411
+ OUTPUT
412
+ end
413
+
414
+ def test_polymorphic_has_one_with_index
415
+ create_table(:users)
416
+ .define_model do
417
+ has_one :account, as: :accountable
418
+ end
419
+
420
+ create_table(:accounts) do |t|
421
+ t.belongs_to :accountable, polymorphic: true, index: { unique: true }
422
+ end.define_model do
423
+ belongs_to :accountable, polymorphic: true
424
+ end
425
+
426
+ refute_problems
427
+ end
428
+
260
429
  def test_config_ignore_models
261
430
  create_table(:users) do |t|
262
431
  t.string :email
@@ -25,6 +25,11 @@ class ActiveRecordDoctor::Detectors::ShortPrimaryKeyTypeTest < Minitest::Test
25
25
  OUTPUT
26
26
  end
27
27
 
28
+ def test_non_integer_and_non_uuid_primary_key_is_not_reported
29
+ create_table(:companies, id: :string, primary_key: :uuid)
30
+ refute_problems
31
+ end
32
+
28
33
  def test_long_integer_primary_key_is_not_reported
29
34
  create_table(:companies, id: :bigint)
30
35
  refute_problems
@@ -10,10 +10,48 @@ class ActiveRecordDoctor::Detectors::UnindexedForeignKeysTest < Minitest::Test
10
10
  end
11
11
 
12
12
  assert_problems(<<~OUTPUT)
13
- add an index on users.company_id - foreign keys are often used in database lookups and should be indexed for performance reasons
13
+ add an index on users(company_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
14
14
  OUTPUT
15
15
  end
16
16
 
17
+ def test_unindexed_foreign_key_with_nonstandard_name_is_reported
18
+ skip("MySQL always indexes foreign keys") if mysql?
19
+
20
+ create_table(:companies)
21
+ create_table(:users) do |t|
22
+ t.integer :company
23
+ t.foreign_key :companies, column: :company
24
+ end
25
+
26
+ assert_problems(<<~OUTPUT)
27
+ add an index on users(company) - foreign keys are often used in database lookups and should be indexed for performance reasons
28
+ OUTPUT
29
+ end
30
+
31
+ def test_unindexed_polymorphic_foreign_key_is_reported
32
+ create_table(:notes) do |t|
33
+ t.integer :notable_id
34
+ t.string :notable_type
35
+ end
36
+
37
+ assert_problems(<<~OUTPUT)
38
+ add an index on notes(notable_type, notable_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
39
+ OUTPUT
40
+ end
41
+
42
+ def test_indexed_polymorphic_foreign_key_is_not_reported
43
+ create_table(:notes) do |t|
44
+ t.string :title
45
+ t.integer :notable_id
46
+ t.string :notable_type
47
+
48
+ # Includes additional column except `notable`
49
+ t.index [:notable_type, :notable_id, :title]
50
+ end
51
+
52
+ refute_problems
53
+ end
54
+
17
55
  def test_indexed_foreign_key_is_not_reported
18
56
  create_table(:companies)
19
57
  create_table(:users) do |t|
@@ -8,6 +8,10 @@ class ActiveRecordDoctor::AddIndexesGeneratorTest < Minitest::Test
8
8
  TIMESTAMP = Time.new(2021, 2, 1, 13, 15, 30)
9
9
 
10
10
  def test_create_migrations
11
+ create_table(:notes) do |t|
12
+ t.integer :notable_id, null: false
13
+ t.string :notable_type, null: false
14
+ end
11
15
  create_table(:users) do |t|
12
16
  t.integer :organization_id, null: false
13
17
  t.integer :account_id, null: false
@@ -21,9 +25,10 @@ class ActiveRecordDoctor::AddIndexesGeneratorTest < Minitest::Test
21
25
 
22
26
  path = File.join(dir, "indexes.txt")
23
27
  File.write(path, <<~INDEXES)
24
- add an index on users.organization_id - foreign keys are often used in database lookups and should be indexed for performance reasons
25
- add an index on users.account_id - foreign keys are often used in database lookups and should be indexed for performance reasons
26
- add an index on organizations.owner_id - foreign keys are often used in database lookups and should be indexed for performance reasons
28
+ add an index on users(organization_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
29
+ add an index on users(account_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
30
+ add an index on organizations(owner_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
31
+ add an index on notes(notable_type, notable_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
27
32
  INDEXES
28
33
 
29
34
  capture_io do
@@ -36,25 +41,30 @@ class ActiveRecordDoctor::AddIndexesGeneratorTest < Minitest::Test
36
41
  load(File.join("db", "migrate", "20210201131531_index_foreign_keys_in_organizations.rb"))
37
42
  IndexForeignKeysInOrganizations.migrate(:up)
38
43
 
44
+ load(File.join("db", "migrate", "20210201131532_index_foreign_keys_in_notes.rb"))
45
+ IndexForeignKeysInNotes.migrate(:up)
46
+
39
47
  ::Object.send(:remove_const, :IndexForeignKeysInUsers)
40
48
  ::Object.send(:remove_const, :IndexForeignKeysInOrganizations)
49
+ ::Object.send(:remove_const, :IndexForeignKeysInNotes)
41
50
  end
42
51
  end
43
52
 
44
53
  assert_indexes([
54
+ ["notes", ["notable_type", "notable_id"]],
45
55
  ["users", ["organization_id"]],
46
56
  ["users", ["account_id"]],
47
57
  ["organizations", ["owner_id"]]
48
58
  ])
49
59
 
50
- assert_equal(4, Dir.entries("./db/migrate").size)
60
+ assert_equal(5, Dir.entries("./db/migrate").size)
51
61
  end
52
62
  end
53
63
 
54
64
  def test_create_migrations_raises_when_malformed_inpout
55
65
  Tempfile.create do |file|
56
66
  file.write(<<~INDEXES)
57
- add an index on users. - foreign keys are often used in database lookups and should be indexed for performance reasons
67
+ add an index on users() - foreign keys are often used in database lookups and should be indexed for performance reasons
58
68
  INDEXES
59
69
  file.flush
60
70
 
@@ -95,7 +105,7 @@ class ActiveRecordDoctor::AddIndexesGeneratorTest < Minitest::Test
95
105
 
96
106
  path = File.join(dir, "indexes.txt")
97
107
  File.write(path, <<~INDEXES)
98
- add an index on organizations_migrated_from_legacy_app.legacy_owner_id_compatible_with_v1_to_v8 - foreign keys are often used in database lookups and should be indexed for performance reasons
108
+ add an index on organizations_migrated_from_legacy_app(legacy_owner_id_compatible_with_v1_to_v8) - foreign keys are often used in database lookups and should be indexed for performance reasons
99
109
  INDEXES
100
110
 
101
111
  capture_io do
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_record_doctor
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.11.0
4
+ version: 1.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Greg Navis
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-03-16 00:00:00.000000000 Z
11
+ date: 2023-07-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -58,14 +58,14 @@ dependencies:
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: 1.1.4
61
+ version: 1.3.0
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: 1.1.4
68
+ version: 1.3.0
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: railties
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -159,6 +159,7 @@ files:
159
159
  - lib/active_record_doctor/railtie.rb
160
160
  - lib/active_record_doctor/rake/task.rb
161
161
  - lib/active_record_doctor/runner.rb
162
+ - lib/active_record_doctor/utils.rb
162
163
  - lib/active_record_doctor/version.rb
163
164
  - lib/generators/active_record_doctor/add_indexes/USAGE
164
165
  - lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb
@@ -201,7 +202,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
201
202
  - !ruby/object:Gem::Version
202
203
  version: '0'
203
204
  requirements: []
204
- rubygems_version: 3.2.33
205
+ rubygems_version: 3.4.10
205
206
  signing_key:
206
207
  specification_version: 4
207
208
  summary: Identify database issues before they hit production.