active_record_doctor 1.11.0 → 1.12.0

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