active_record_doctor 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +83 -19
  3. data/lib/active_record_doctor/config/default.rb +17 -0
  4. data/lib/active_record_doctor/detectors/base.rb +216 -56
  5. data/lib/active_record_doctor/detectors/extraneous_indexes.rb +38 -56
  6. data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +2 -6
  7. data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +88 -15
  8. data/lib/active_record_doctor/detectors/incorrect_length_validation.rb +60 -0
  9. data/lib/active_record_doctor/detectors/mismatched_foreign_key_type.rb +16 -9
  10. data/lib/active_record_doctor/detectors/missing_foreign_keys.rb +2 -4
  11. data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +14 -11
  12. data/lib/active_record_doctor/detectors/missing_presence_validation.rb +16 -10
  13. data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +61 -17
  14. data/lib/active_record_doctor/detectors/short_primary_key_type.rb +6 -2
  15. data/lib/active_record_doctor/detectors/undefined_table_references.rb +2 -4
  16. data/lib/active_record_doctor/detectors/unindexed_deleted_at.rb +6 -15
  17. data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +2 -4
  18. data/lib/active_record_doctor/logger/dummy.rb +11 -0
  19. data/lib/active_record_doctor/logger/hierarchical.rb +22 -0
  20. data/lib/active_record_doctor/logger.rb +6 -0
  21. data/lib/active_record_doctor/rake/task.rb +10 -1
  22. data/lib/active_record_doctor/runner.rb +8 -3
  23. data/lib/active_record_doctor/version.rb +1 -1
  24. data/lib/active_record_doctor.rb +4 -0
  25. data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +5 -5
  26. data/test/active_record_doctor/detectors/disable_test.rb +30 -0
  27. data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +34 -0
  28. data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +7 -7
  29. data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +220 -43
  30. data/test/active_record_doctor/detectors/incorrect_length_validation_test.rb +107 -0
  31. data/test/active_record_doctor/detectors/mismatched_foreign_key_type_test.rb +35 -1
  32. data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +78 -21
  33. data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +89 -25
  34. data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +179 -15
  35. data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +27 -19
  36. data/test/active_record_doctor/detectors/undefined_table_references_test.rb +11 -13
  37. data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +9 -3
  38. data/test/active_record_doctor/runner_test.rb +18 -19
  39. data/test/setup.rb +15 -7
  40. metadata +25 -5
  41. data/test/model_factory.rb +0 -128
@@ -33,82 +33,64 @@ module ActiveRecordDoctor
33
33
  end
34
34
 
35
35
  def subindexes_of_multi_column_indexes
36
- tables(except: config(:ignore_tables)).each do |table|
37
- indexes = indexes(table)
38
- maximal_indexes = indexes.select { |index| maximal?(indexes, index) }
39
-
40
- indexes.each do |index|
41
- next if maximal_indexes.include?(index)
42
-
43
- replacement_indexes = maximal_indexes.select do |maximum_index|
44
- cover?(maximum_index, index)
45
- end.map(&:name).sort
46
-
47
- next if config(:ignore_indexes).include?(index.name)
48
-
49
- problem!(extraneous_index: index.name, replacement_indexes: replacement_indexes)
36
+ log(__method__) do
37
+ each_table(except: config(:ignore_tables)) do |table|
38
+ each_index(table, except: config(:ignore_indexes), multicolumn_only: true) do |index, indexes|
39
+ replacement_indexes = indexes.select do |other_index|
40
+ index != other_index && replaceable_with?(index, other_index)
41
+ end
42
+
43
+ if replacement_indexes.empty?
44
+ log("Found no replacement indexes; skipping")
45
+ next
46
+ end
47
+
48
+ problem!(
49
+ extraneous_index: index.name,
50
+ replacement_indexes: replacement_indexes.map(&:name).sort
51
+ )
52
+ end
50
53
  end
51
54
  end
52
55
  end
53
56
 
54
57
  def indexed_primary_keys
55
- tables(except: config(:ignore_tables)).each do |table|
56
- indexes(table).each do |index|
57
- next if config(:ignore_indexes).include?(index.name)
58
- next if index.columns != ["id"]
59
-
60
- problem!(extraneous_index: index.name, replacement_indexes: nil)
58
+ log(__method__) do
59
+ each_table(except: config(:ignore_tables)) do |table|
60
+ each_index(table, except: config(:ignore_indexes), multicolumn_only: true) do |index|
61
+ primary_key = connection.primary_key(table)
62
+ if index.columns == [primary_key] && index.where.nil?
63
+ problem!(extraneous_index: index.name, replacement_indexes: nil)
64
+ end
65
+ end
61
66
  end
62
67
  end
63
68
  end
64
69
 
65
- def maximal?(indexes, index)
66
- indexes.all? do |another_index|
67
- index == another_index || !cover?(another_index, index)
68
- end
69
- end
70
-
71
- # Does lhs cover rhs?
72
- def cover?(lhs, rhs)
73
- return false unless compatible_options?(lhs, rhs)
70
+ def replaceable_with?(index1, index2)
71
+ return false if index1.type != index2.type
72
+ return false if index1.using != index2.using
73
+ return false if index1.where != index2.where
74
+ return false if opclasses(index1) != opclasses(index2)
74
75
 
75
- case [lhs.unique, rhs.unique]
76
+ case [index1.unique, index2.unique]
76
77
  when [true, true]
77
- lhs.columns == rhs.columns
78
- when [false, true]
78
+ (index2.columns - index1.columns).empty?
79
+ when [true, false]
79
80
  false
80
81
  else
81
- prefix?(rhs, lhs)
82
+ prefix?(index1, index2)
82
83
  end
83
84
  end
84
85
 
86
+ def opclasses(index)
87
+ index.respond_to?(:opclasses) ? index.opclasses : nil
88
+ end
89
+
85
90
  def prefix?(lhs, rhs)
86
91
  lhs.columns.count <= rhs.columns.count &&
87
92
  rhs.columns[0...lhs.columns.count] == lhs.columns
88
93
  end
89
-
90
- def indexes(table_name)
91
- super.select { |index| index.columns.is_a?(Array) }
92
- end
93
-
94
- def compatible_options?(lhs, rhs)
95
- lhs.type == rhs.type &&
96
- lhs.using == rhs.using &&
97
- lhs.where == rhs.where &&
98
- same_opclasses?(lhs, rhs)
99
- end
100
-
101
- def same_opclasses?(lhs, rhs)
102
- if ActiveRecord::VERSION::STRING >= "5.2"
103
- rhs.columns.all? do |column|
104
- lhs_opclass = lhs.opclasses.is_a?(Hash) ? lhs.opclasses[column] : lhs.opclasses
105
- rhs_opclass = rhs.opclasses.is_a?(Hash) ? rhs.opclasses[column] : rhs.opclasses
106
- lhs_opclass == rhs_opclass
107
- end
108
- else
109
- true
110
- end
111
- end
112
94
  end
113
95
  end
114
96
  end
@@ -25,12 +25,8 @@ module ActiveRecordDoctor
25
25
  end
26
26
 
27
27
  def detect
28
- models(except: config(:ignore_models)).each do |model|
29
- next if model.table_name.nil?
30
- next unless table_exists?(model.table_name)
31
-
32
- connection.columns(model.table_name).each do |column|
33
- next if config(:ignore_attributes).include?("#{model.name}.#{column.name}")
28
+ each_model(except: config(:ignore_models), existing_tables_only: true) do |model|
29
+ each_attribute(model, except: config(:ignore_attributes)) do |column|
34
30
  next unless column.type == :boolean
35
31
  next unless has_presence_validator?(model, column)
36
32
 
@@ -18,31 +18,77 @@ module ActiveRecordDoctor
18
18
 
19
19
  private
20
20
 
21
- def message(model:, association:, problem:)
21
+ def message(model:, association:, problem:, associated_models:, associated_models_type:)
22
+ associated_models.sort!
23
+
24
+ models_part =
25
+ if associated_models.length == 1
26
+ "model #{associated_models[0]} has"
27
+ else
28
+ "models #{associated_models.join(', ')} have"
29
+ end
30
+
31
+ if associated_models_type
32
+ models_part = "#{associated_models_type} #{models_part}"
33
+ end
34
+
22
35
  # rubocop:disable Layout/LineLength
23
36
  case problem
37
+ when :invalid_through
38
+ "ensure #{model}.#{association} is configured correctly - #{associated_models[0]}.#{association} may be undefined"
24
39
  when :suggest_destroy
25
- "use `dependent: :destroy` or similar on #{model}.#{association} - the associated model has callbacks that are currently skipped"
40
+ "use `dependent: :destroy` or similar on #{model}.#{association} - associated #{models_part} callbacks that are currently skipped"
26
41
  when :suggest_delete
27
- "use `dependent: :delete` or similar on #{model}.#{association} - the associated model has no callbacks and can be deleted without loading"
42
+ "use `dependent: :delete` or similar on #{model}.#{association} - associated #{models_part} no callbacks and can be deleted without loading"
28
43
  when :suggest_delete_all
29
- "use `dependent: :delete_all` or similar on #{model}.#{association} - associated models have no validations and can be deleted in bulk"
44
+ "use `dependent: :delete_all` or similar on #{model}.#{association} - associated #{models_part} no callbacks and can be deleted in bulk"
30
45
  end
31
46
  # rubocop:enable Layout/LineLength
32
47
  end
33
48
 
34
49
  def detect
35
- models(except: config(:ignore_models)).each do |model|
36
- next if model.table_name.nil?
50
+ each_model(except: config(:ignore_models), existing_tables_only: true) do |model|
51
+ each_association(model, except: config(:ignore_associations)) do |association|
52
+ # A properly configured :through association will have a non-nil
53
+ # source_reflection. If it's nil then it indicates the :through
54
+ # model lacks the next leg in the :through relationship. For
55
+ # instance, if user has many comments through posts then a nil
56
+ # source_reflection means that Post doesn't define +has_many :comments+.
57
+ if through?(association) && association.source_reflection.nil?
58
+ log("through association with nil source_reflection")
37
59
 
38
- associations = model.reflect_on_all_associations(:has_many) +
39
- model.reflect_on_all_associations(:has_one) +
40
- model.reflect_on_all_associations(:belongs_to)
60
+ through_association = model.reflect_on_association(association.options.fetch(:through))
61
+ association_on_join_model = through_association.klass.reflect_on_association(association.name)
41
62
 
42
- associations.each do |association|
43
- next if config(:ignore_associations).include?("#{model.name}.#{association.name}")
63
+ # We report a problem only if the +has_many+ association mentioned
64
+ # above is actually missing. We let the detector continue in other
65
+ # cases, risking an exception, as the absence of source_reflection
66
+ # must be caused by something else in those cases. Each further
67
+ # exception will be handled on a case-by-case basis.
68
+ if association_on_join_model.nil?
69
+ problem!(
70
+ model: model.name,
71
+ association: association.name,
72
+ problem: :invalid_through,
73
+ associated_models: [through_association.klass.name],
74
+ associated_models_type: "join"
75
+ )
76
+ next
77
+ end
78
+ end
79
+
80
+ associated_models, associated_models_type =
81
+ if association.polymorphic?
82
+ [models_having_association_with_options(as: association.name), nil]
83
+ elsif through?(association)
84
+ [[association.source_reflection.active_record], "join"]
85
+ else
86
+ [[association.klass], nil]
87
+ end
44
88
 
45
- if callback_action(association) == :invoke && deletable?(association.klass)
89
+ deletable_models, destroyable_models = associated_models.partition { |klass| deletable?(klass) }
90
+
91
+ if callback_action(association) == :invoke && destroyable_models.empty? && deletable_models.present?
46
92
  suggestion =
47
93
  case association.macro
48
94
  when :has_many then :suggest_delete_all
@@ -50,14 +96,37 @@ module ActiveRecordDoctor
50
96
  else raise("unsupported association type #{association.macro}")
51
97
  end
52
98
 
53
- problem!(model: model.name, association: association.name, problem: suggestion)
54
- elsif callback_action(association) == :skip && !deletable?(association.klass)
55
- problem!(model: model.name, association: association.name, problem: :suggest_destroy)
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
+ )
56
114
  end
57
115
  end
58
116
  end
59
117
  end
60
118
 
119
+ def models_having_association_with_options(as:)
120
+ models.select do |model|
121
+ associations = model.reflect_on_all_associations(:has_one) +
122
+ model.reflect_on_all_associations(:has_many)
123
+
124
+ associations.any? do |association|
125
+ association.options[:as] == as
126
+ end
127
+ end
128
+ end
129
+
61
130
  def callback_action(reflection)
62
131
  case reflection.options[:dependent]
63
132
  when :delete, :delete_all then :skip
@@ -77,6 +146,10 @@ module ActiveRecordDoctor
77
146
  end
78
147
  end
79
148
 
149
+ def through?(reflection)
150
+ reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
151
+ end
152
+
80
153
  def defines_destroy_callbacks?(model)
81
154
  # Destroying an associated model involves loading it first hence
82
155
  # initialize and find are present. If they are defined on the model
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ class IncorrectLengthValidation < Base # :nodoc:
8
+ @description = "detect mismatches between database length limits and model length validations"
9
+ @config = {
10
+ ignore_models: {
11
+ description: "models whose validators should not be checked",
12
+ global: true
13
+ },
14
+ ignore_attributes: {
15
+ description: "attributes, written as Model.attribute, whose validators should not be checked"
16
+ }
17
+ }
18
+
19
+ private
20
+
21
+ def message(model:, attribute:, table:, database_maximum:, model_maximum:)
22
+ # rubocop:disable Layout/LineLength
23
+ if database_maximum && model_maximum
24
+ "the schema limits #{table}.#{attribute} to #{database_maximum} characters but the length validator on #{model}.#{attribute} enforces a maximum of #{model_maximum} characters - set both limits to the same value or remove both"
25
+ elsif database_maximum && model_maximum.nil?
26
+ "the schema limits #{table}.#{attribute} to #{database_maximum} characters but there's no length validator on #{model}.#{attribute} - remove the database limit or add the validator"
27
+ elsif database_maximum.nil? && model_maximum
28
+ "the length validator on #{model}.#{attribute} enforces a maximum of #{model_maximum} characters but there's no schema limit on #{table}.#{attribute} - remove the validator or the schema length limit"
29
+ end
30
+ # rubocop:enable Layout/LineLength
31
+ end
32
+
33
+ def detect
34
+ each_model(except: config(:ignore_models), existing_tables_only: true) do |model|
35
+ each_attribute(model, except: config(:ignore_attributes), type: [:string, :text]) do |column|
36
+ model_maximum = maximum_allowed_by_validations(model, column.name.to_sym)
37
+ next if model_maximum == column.limit
38
+
39
+ problem!(
40
+ model: model.name,
41
+ attribute: column.name,
42
+ table: model.table_name,
43
+ database_maximum: column.limit,
44
+ model_maximum: model_maximum
45
+ )
46
+ end
47
+ end
48
+ end
49
+
50
+ def maximum_allowed_by_validations(model, column)
51
+ length_validator = model.validators.find do |validator|
52
+ validator.kind == :length &&
53
+ validator.options.include?(:maximum) &&
54
+ validator.attributes.include?(column)
55
+ end
56
+ length_validator ? length_validator.options[:maximum] : nil
57
+ end
58
+ end
59
+ end
60
+ end
@@ -18,25 +18,32 @@ module ActiveRecordDoctor
18
18
 
19
19
  private
20
20
 
21
- def message(table:, column:)
21
+ def message(from_table:, from_column:, from_type:, to_table:, to_column:, to_type:)
22
22
  # rubocop:disable Layout/LineLength
23
- "#{table}.#{column} references a column of different type - foreign keys should be of the same type as the referenced column"
23
+ "#{from_table}.#{from_column} is a foreign key of type #{from_type} and references #{to_table}.#{to_column} of type #{to_type} - foreign keys should be of the same type as the referenced column"
24
24
  # rubocop:enable Layout/LineLength
25
25
  end
26
26
 
27
27
  def detect
28
- tables(except: config(:ignore_tables)).each do |table|
29
- connection.foreign_keys(table).each do |foreign_key|
28
+ each_table(except: config(:ignore_tables)) do |table|
29
+ each_foreign_key(table) do |foreign_key|
30
30
  from_column = column(table, foreign_key.column)
31
31
 
32
32
  next if config(:ignore_columns).include?("#{table}.#{from_column.name}")
33
33
 
34
34
  to_table = foreign_key.to_table
35
- primary_key = primary_key(to_table)
36
-
37
- next if from_column.sql_type == primary_key.sql_type
38
-
39
- problem!(table: table, column: from_column.name)
35
+ to_column = column(to_table, foreign_key.primary_key)
36
+
37
+ next if from_column.sql_type == to_column.sql_type
38
+
39
+ problem!(
40
+ from_table: table,
41
+ from_column: from_column.name,
42
+ from_type: from_column.sql_type,
43
+ to_table: to_table,
44
+ to_column: to_column.name,
45
+ to_type: to_column.sql_type
46
+ )
40
47
  end
41
48
  end
42
49
  end
@@ -23,10 +23,8 @@ module ActiveRecordDoctor
23
23
  end
24
24
 
25
25
  def detect
26
- tables(except: config(:ignore_tables)).each do |table|
27
- connection.columns(table).each do |column|
28
- next if config(:ignore_columns).include?("#{table}.#{column.name}")
29
-
26
+ each_table(except: config(:ignore_tables)) do |table|
27
+ each_column(table, except: config(:ignore_columns)) do |column|
30
28
  # We need to skip polymorphic associations as they can reference
31
29
  # multiple tables but a foreign key constraint can reference
32
30
  # a single predefined table.
@@ -23,8 +23,7 @@ module ActiveRecordDoctor
23
23
  end
24
24
 
25
25
  def detect
26
- table_models = models.group_by(&:table_name)
27
- table_models.delete_if { |table| table.nil? || !table_exists?(table) }
26
+ table_models = models.select(&:table_exists?).group_by(&:table_name)
28
27
 
29
28
  table_models.each do |table, models|
30
29
  next if config(:ignore_tables).include?(table)
@@ -37,6 +36,7 @@ module ActiveRecordDoctor
37
36
  next if config(:ignore_columns).include?("#{table}.#{column.name}")
38
37
  next if !column.null
39
38
  next if !concrete_models.all? { |model| non_null_needed?(model, column) }
39
+ next if not_null_check_constraint_exists?(table, column)
40
40
 
41
41
  problem!(column: column.name, table: table)
42
42
  end
@@ -49,19 +49,22 @@ module ActiveRecordDoctor
49
49
  end
50
50
 
51
51
  def non_null_needed?(model, column)
52
- # A foreign key can be validates via the column name (e.g. company_id)
53
- # or the association name (e.g. company). We collect the allowed names
54
- # in an array to check for their presence in the validator definition
55
- # in one go.
56
- attribute_name_forms = [column.name.to_sym]
57
52
  belongs_to = model.reflect_on_all_associations(:belongs_to).find do |reflection|
58
- reflection.foreign_key == column.name
53
+ reflection.foreign_key == column.name ||
54
+ (reflection.polymorphic? && reflection.foreign_type == column.name)
59
55
  end
60
- attribute_name_forms << belongs_to.name.to_sym if belongs_to
61
56
 
62
- model.validators.any? do |validator|
57
+ required_presence_validators(model).any? do |validator|
58
+ attributes = validator.attributes
59
+
60
+ attributes.include?(column.name.to_sym) ||
61
+ (belongs_to && attributes.include?(belongs_to.name.to_sym))
62
+ end
63
+ end
64
+
65
+ def required_presence_validators(model)
66
+ model.validators.select do |validator|
63
67
  validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
64
- (validator.attributes & attribute_name_forms).present? &&
65
68
  !validator.options[:allow_nil] &&
66
69
  !validator.options[:if] &&
67
70
  !validator.options[:unless]
@@ -23,14 +23,10 @@ module ActiveRecordDoctor
23
23
  end
24
24
 
25
25
  def detect
26
- models(except: config(:ignore_models)).each do |model|
27
- next if model.table_name.nil?
28
- next unless table_exists?(model.table_name)
29
-
30
- connection.columns(model.table_name).each do |column|
26
+ each_model(except: config(:ignore_models), existing_tables_only: true) do |model|
27
+ each_attribute(model, except: config(:ignore_attributes)) do |column|
31
28
  next unless validator_needed?(model, column)
32
29
  next if validator_present?(model, column)
33
- next if config(:ignore_attributes).include?("#{model}.#{column.name}")
34
30
 
35
31
  problem!(column: column.name, model: model.name)
36
32
  end
@@ -38,8 +34,8 @@ module ActiveRecordDoctor
38
34
  end
39
35
 
40
36
  def validator_needed?(model, column)
41
- ![model.primary_key, "created_at", "updated_at"].include?(column.name) &&
42
- !column.null
37
+ ![model.primary_key, "created_at", "updated_at", "created_on", "updated_on"].include?(column.name) &&
38
+ (!column.null || not_null_check_constraint_exists?(model.table_name, column))
43
39
  end
44
40
 
45
41
  def validator_present?(model, column)
@@ -53,17 +49,23 @@ module ActiveRecordDoctor
53
49
 
54
50
  def inclusion_validator_present?(model, column)
55
51
  model.validators.any? do |validator|
52
+ validator_items = inclusion_validator_items(validator)
53
+ return true if validator_items.is_a?(Proc)
54
+
56
55
  validator.is_a?(ActiveModel::Validations::InclusionValidator) &&
57
56
  validator.attributes.include?(column.name.to_sym) &&
58
- !validator.options.fetch(:in, []).include?(nil)
57
+ !validator_items.include?(nil)
59
58
  end
60
59
  end
61
60
 
62
61
  def exclusion_validator_present?(model, column)
63
62
  model.validators.any? do |validator|
63
+ validator_items = inclusion_validator_items(validator)
64
+ return true if validator_items.is_a?(Proc)
65
+
64
66
  validator.is_a?(ActiveModel::Validations::ExclusionValidator) &&
65
67
  validator.attributes.include?(column.name.to_sym) &&
66
- validator.options.fetch(:in, []).include?(nil)
68
+ validator_items.include?(nil)
67
69
  end
68
70
  end
69
71
 
@@ -80,6 +82,10 @@ module ActiveRecordDoctor
80
82
  (validator.attributes & allowed_attributes).present?
81
83
  end
82
84
  end
85
+
86
+ def inclusion_validator_items(validator)
87
+ validator.options[:in] || validator.options[:within] || []
88
+ end
83
89
  end
84
90
  end
85
91
  end
@@ -18,31 +18,54 @@ module ActiveRecordDoctor
18
18
 
19
19
  private
20
20
 
21
- def message(table:, columns:)
22
- # rubocop:disable Layout/LineLength
23
- "add a unique index on #{table}(#{columns.join(', ')}) - validating uniqueness in the model without an index can lead to duplicates"
24
- # rubocop:enable Layout/LineLength
21
+ # rubocop:disable Layout/LineLength
22
+ def message(model:, table:, columns:, problem:)
23
+ case problem
24
+ when :validations
25
+ "add a unique index on #{table}(#{columns.join(', ')}) - validating uniqueness in the model without an index can lead to duplicates"
26
+ 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"
28
+ end
25
29
  end
30
+ # rubocop:enable Layout/LineLength
26
31
 
27
32
  def detect
28
- ignore_columns = config(:ignore_columns).map do |column|
29
- column.gsub(" ", "")
30
- end
31
-
32
- models(except: config(:ignore_models)).each do |model|
33
- next if model.table_name.nil?
33
+ validations_without_indexes
34
+ has_ones_without_indexes
35
+ end
34
36
 
37
+ def validations_without_indexes
38
+ each_model(except: config(:ignore_models), existing_tables_only: true) do |model|
35
39
  model.validators.each do |validator|
36
40
  scope = Array(validator.options.fetch(:scope, []))
37
41
 
38
42
  next unless validator.is_a?(ActiveRecord::Validations::UniquenessValidator)
39
43
  next unless supported_validator?(validator)
40
- next if unique_index?(model.table_name, validator.attributes, scope)
41
44
 
42
- columns = (scope + validator.attributes).map(&:to_s)
43
- next if ignore_columns.include?("#{model.name}(#{columns.join(',')})")
45
+ validator.attributes.each do |attribute|
46
+ columns = resolve_attributes(model, scope + [attribute])
47
+
48
+ next if unique_index?(model.table_name, columns)
49
+ next if ignore_columns.include?("#{model.name}(#{columns.join(',')})")
50
+
51
+ problem!(model: model, table: model.table_name, columns: columns, problem: :validations)
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ def has_ones_without_indexes # rubocop:disable Naming/PredicateName
58
+ each_model do |model|
59
+ each_association(model, type: :has_one, has_scope: false, through: false) do |has_one|
60
+ next if config(:ignore_models).include?(has_one.klass.name)
44
61
 
45
- problem!(table: model.table_name, columns: columns)
62
+ foreign_key = has_one.foreign_key
63
+ next if ignore_columns.include?(foreign_key.to_s)
64
+
65
+ table_name = has_one.klass.table_name
66
+ next if unique_index?(table_name, [foreign_key])
67
+
68
+ problem!(model: model, table: table_name, columns: [foreign_key], problem: :has_ones)
46
69
  end
47
70
  end
48
71
  end
@@ -59,11 +82,32 @@ module ActiveRecordDoctor
59
82
  validator.options.fetch(:case_sensitive, true)
60
83
  end
61
84
 
62
- def unique_index?(table_name, columns, scope)
63
- columns = (Array(scope) + columns).map(&:to_s)
85
+ def resolve_attributes(model, attributes)
86
+ attributes.flat_map do |attribute|
87
+ reflection = model.reflect_on_association(attribute)
88
+
89
+ if reflection.nil?
90
+ attribute
91
+ elsif reflection.polymorphic?
92
+ [reflection.foreign_type, reflection.foreign_key]
93
+ else
94
+ reflection.foreign_key
95
+ end
96
+ end.map(&:to_s)
97
+ end
64
98
 
99
+ def unique_index?(table_name, columns, scope = nil)
100
+ columns = (Array(scope) + columns).map(&:to_s)
65
101
  indexes(table_name).any? do |index|
66
- index.columns.to_set == columns.to_set && index.unique
102
+ index.unique &&
103
+ index.where.nil? &&
104
+ (Array(index.columns) - columns).empty?
105
+ end
106
+ end
107
+
108
+ def ignore_columns
109
+ @ignore_columns ||= config(:ignore_columns).map do |column|
110
+ column.gsub(" ", "")
67
111
  end
68
112
  end
69
113
  end
@@ -20,10 +20,10 @@ module ActiveRecordDoctor
20
20
  end
21
21
 
22
22
  def detect
23
- tables(except: config(:ignore_tables)).each do |table|
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)
26
+ next if bigint?(column) || uuid?(column)
27
27
 
28
28
  problem!(table: table, column: column.name)
29
29
  end
@@ -36,6 +36,10 @@ module ActiveRecordDoctor
36
36
  /\Abigint\b/.match?(column.sql_type)
37
37
  end
38
38
  end
39
+
40
+ def uuid?(column)
41
+ column.sql_type == "uuid"
42
+ end
39
43
  end
40
44
  end
41
45
  end
@@ -20,10 +20,8 @@ module ActiveRecordDoctor
20
20
  end
21
21
 
22
22
  def detect
23
- models(except: config(:ignore_models)).each do |model|
24
- next if model.table_name.nil?
25
- next if tables.include?(model.table_name)
26
- next if tables_and_views.include?(model.table_name)
23
+ each_model(except: config(:ignore_models), abstract: false) do |model|
24
+ next if connection.data_source_exists?(model.table_name)
27
25
 
28
26
  problem!(model: model.name, table: model.table_name)
29
27
  end