active_record_doctor 1.10.0 → 1.12.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -15
  3. data/lib/active_record_doctor/detectors/base.rb +194 -53
  4. data/lib/active_record_doctor/detectors/extraneous_indexes.rb +36 -34
  5. data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +2 -5
  6. data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +87 -37
  7. data/lib/active_record_doctor/detectors/incorrect_length_validation.rb +7 -10
  8. data/lib/active_record_doctor/detectors/mismatched_foreign_key_type.rb +16 -9
  9. data/lib/active_record_doctor/detectors/missing_foreign_keys.rb +2 -4
  10. data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +13 -11
  11. data/lib/active_record_doctor/detectors/missing_presence_validation.rb +14 -7
  12. data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +70 -35
  13. data/lib/active_record_doctor/detectors/short_primary_key_type.rb +4 -4
  14. data/lib/active_record_doctor/detectors/undefined_table_references.rb +2 -2
  15. data/lib/active_record_doctor/detectors/unindexed_deleted_at.rb +5 -13
  16. data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +35 -11
  17. data/lib/active_record_doctor/logger/dummy.rb +11 -0
  18. data/lib/active_record_doctor/logger/hierarchical.rb +22 -0
  19. data/lib/active_record_doctor/logger.rb +6 -0
  20. data/lib/active_record_doctor/rake/task.rb +10 -1
  21. data/lib/active_record_doctor/runner.rb +8 -3
  22. data/lib/active_record_doctor/utils.rb +21 -0
  23. data/lib/active_record_doctor/version.rb +1 -1
  24. data/lib/active_record_doctor.rb +5 -0
  25. data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +14 -14
  26. data/test/active_record_doctor/detectors/disable_test.rb +1 -1
  27. data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +59 -6
  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 +175 -57
  30. data/test/active_record_doctor/detectors/incorrect_length_validation_test.rb +16 -14
  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 +46 -23
  33. data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +55 -27
  34. data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +216 -47
  35. data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +5 -0
  36. data/test/active_record_doctor/detectors/undefined_table_references_test.rb +11 -13
  37. data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +39 -1
  38. data/test/active_record_doctor/runner_test.rb +18 -19
  39. data/test/generators/active_record_doctor/add_indexes/add_indexes_generator_test.rb +16 -6
  40. data/test/setup.rb +10 -6
  41. metadata +23 -7
  42. data/test/model_factory.rb +0 -128
@@ -18,7 +18,8 @@ module ActiveRecordDoctor
18
18
 
19
19
  private
20
20
 
21
- def message(model:, association:, problem:, associated_models:)
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 =
@@ -28,57 +29,109 @@ module ActiveRecordDoctor
28
29
  "models #{associated_models.join(', ')} have"
29
30
  end
30
31
 
32
+ if associated_models_type
33
+ models_part = "#{associated_models_type} #{models_part}"
34
+ end
35
+
31
36
  # rubocop:disable Layout/LineLength
32
37
  case problem
38
+ when :invalid_through
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}"
33
43
  when :suggest_destroy
34
- "use `dependent: :destroy` or similar on #{model}.#{association} - the associated #{models_part} callbacks that are currently skipped"
44
+ "use `dependent: :destroy` or similar on #{model}.#{association} - associated #{models_part} callbacks that are currently skipped"
35
45
  when :suggest_delete
36
- "use `dependent: :delete` or similar on #{model}.#{association} - the associated #{models_part} no callbacks and can be deleted without loading"
46
+ "use `dependent: :delete` or similar on #{model}.#{association} - associated #{models_part} no callbacks and can be deleted without loading"
37
47
  when :suggest_delete_all
38
- "use `dependent: :delete_all` or similar on #{model}.#{association} - associated #{models_part} no validations and can be deleted in bulk"
48
+ "use `dependent: :delete_all` or similar on #{model}.#{association} - associated #{models_part} no callbacks and can be deleted in bulk"
39
49
  end
40
50
  # rubocop:enable Layout/LineLength
41
51
  end
42
52
 
43
53
  def detect
44
- models(except: config(:ignore_models)).each do |model|
45
- next unless model.table_exists?
46
-
47
- associations = model.reflect_on_all_associations(:has_many) +
48
- model.reflect_on_all_associations(:has_one) +
49
- model.reflect_on_all_associations(:belongs_to)
50
-
51
- associations.each do |association|
52
- next if config(:ignore_associations).include?("#{model.name}.#{association.name}")
54
+ each_model(except: config(:ignore_models), existing_tables_only: true) do |model|
55
+ each_association(model, except: config(:ignore_associations)) do |association|
56
+ # A properly configured :through association will have a non-nil
57
+ # source_reflection. If it's nil then it indicates the :through
58
+ # model lacks the next leg in the :through relationship. For
59
+ # instance, if user has many comments through posts then a nil
60
+ # source_reflection means that Post doesn't define +has_many :comments+.
61
+ if through?(association) && association.source_reflection.nil?
62
+ log("through association with nil source_reflection")
63
+
64
+ through_association = model.reflect_on_association(association.options.fetch(:through))
65
+ association_on_join_model = through_association.klass.reflect_on_association(association.name)
66
+
67
+ # We report a problem only if the +has_many+ association mentioned
68
+ # above is actually missing. We let the detector continue in other
69
+ # cases, risking an exception, as the absence of source_reflection
70
+ # must be caused by something else in those cases. Each further
71
+ # exception will be handled on a case-by-case basis.
72
+ if association_on_join_model.nil?
73
+ problem!(
74
+ model: model.name,
75
+ association: association.name,
76
+ problem: :invalid_through,
77
+ associated_models: [through_association.klass.name],
78
+ associated_models_type: "join"
79
+ )
80
+ next
81
+ end
82
+ end
53
83
 
54
- associated_models =
84
+ associated_models, associated_models_type =
55
85
  if association.polymorphic?
56
- models_having(as: association.name)
86
+ [models_having_association_with_options(as: association.name), nil]
87
+ elsif through?(association)
88
+ [[association.source_reflection.active_record], "join"]
57
89
  else
58
- [association.klass]
90
+ [[association.klass], nil]
59
91
  end
60
92
 
61
93
  deletable_models, destroyable_models = associated_models.partition { |klass| deletable?(klass) }
62
94
 
63
- if callback_action(association) == :invoke && destroyable_models.empty? && deletable_models.present?
64
- suggestion =
65
- case association.macro
66
- when :has_many then :suggest_delete_all
67
- when :has_one, :belongs_to then :suggest_delete
68
- else raise("unsupported association type #{association.macro}")
69
- end
70
-
71
- problem!(model: model.name, association: association.name, problem: suggestion,
72
- associated_models: deletable_models.map(&:name))
73
- elsif callback_action(association) == :skip && destroyable_models.present?
74
- problem!(model: model.name, association: association.name, problem: :suggest_destroy,
75
- associated_models: destroyable_models.map(&:name))
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
76
129
  end
77
130
  end
78
131
  end
79
132
  end
80
133
 
81
- def models_having(as:)
134
+ def models_having_association_with_options(as:)
82
135
  models.select do |model|
83
136
  associations = model.reflect_on_all_associations(:has_one) +
84
137
  model.reflect_on_all_associations(:has_many)
@@ -89,13 +142,6 @@ module ActiveRecordDoctor
89
142
  end
90
143
  end
91
144
 
92
- def callback_action(reflection)
93
- case reflection.options[:dependent]
94
- when :delete, :delete_all then :skip
95
- when :destroy then :invoke
96
- end
97
- end
98
-
99
145
  def deletable?(model)
100
146
  !defines_destroy_callbacks?(model) &&
101
147
  dependent_models(model).all? do |dependent_model|
@@ -108,6 +154,10 @@ module ActiveRecordDoctor
108
154
  end
109
155
  end
110
156
 
157
+ def through?(reflection)
158
+ reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
159
+ end
160
+
111
161
  def defines_destroy_callbacks?(model)
112
162
  # Destroying an associated model involves loading it first hence
113
163
  # initialize and find are present. If they are defined on the model
@@ -31,14 +31,9 @@ module ActiveRecordDoctor
31
31
  end
32
32
 
33
33
  def detect
34
- models(except: config(:ignore_models)).each do |model|
35
- next unless model.table_exists?
36
-
37
- connection.columns(model.table_name).each do |column|
38
- next if config(:ignore_attributes).include?("#{model.name}.#{column.name}")
39
- next if ![:string, :text].include?(column.type)
40
-
41
- model_maximum = maximum_allowed_by_validations(model)
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)
42
37
  next if model_maximum == column.limit
43
38
 
44
39
  problem!(
@@ -52,9 +47,11 @@ module ActiveRecordDoctor
52
47
  end
53
48
  end
54
49
 
55
- def maximum_allowed_by_validations(model)
50
+ def maximum_allowed_by_validations(model, column)
56
51
  length_validator = model.validators.find do |validator|
57
- validator.kind == :length && validator.options.include?(:maximum)
52
+ validator.kind == :length &&
53
+ validator.options.include?(:maximum) &&
54
+ validator.attributes.include?(column)
58
55
  end
59
56
  length_validator ? length_validator.options[:maximum] : nil
60
57
  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, models| !models.first.table_exists? }
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)
@@ -50,19 +49,22 @@ module ActiveRecordDoctor
50
49
  end
51
50
 
52
51
  def non_null_needed?(model, column)
53
- # A foreign key can be validates via the column name (e.g. company_id)
54
- # or the association name (e.g. company). We collect the allowed names
55
- # in an array to check for their presence in the validator definition
56
- # in one go.
57
- attribute_name_forms = [column.name.to_sym]
58
52
  belongs_to = model.reflect_on_all_associations(:belongs_to).find do |reflection|
59
- reflection.foreign_key == column.name
53
+ reflection.foreign_key == column.name ||
54
+ (reflection.polymorphic? && reflection.foreign_type == column.name)
60
55
  end
61
- attribute_name_forms << belongs_to.name.to_sym if belongs_to
62
56
 
63
- 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|
64
67
  validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
65
- (validator.attributes & attribute_name_forms).present? &&
66
68
  !validator.options[:allow_nil] &&
67
69
  !validator.options[:if] &&
68
70
  !validator.options[:unless]
@@ -23,13 +23,10 @@ module ActiveRecordDoctor
23
23
  end
24
24
 
25
25
  def detect
26
- models(except: config(:ignore_models)).each do |model|
27
- next unless model.table_exists?
28
-
29
- 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|
30
28
  next unless validator_needed?(model, column)
31
29
  next if validator_present?(model, column)
32
- next if config(:ignore_attributes).include?("#{model}.#{column.name}")
33
30
 
34
31
  problem!(column: column.name, model: model.name)
35
32
  end
@@ -52,17 +49,23 @@ module ActiveRecordDoctor
52
49
 
53
50
  def inclusion_validator_present?(model, column)
54
51
  model.validators.any? do |validator|
52
+ validator_items = inclusion_validator_items(validator)
53
+ return true if validator_items.is_a?(Proc)
54
+
55
55
  validator.is_a?(ActiveModel::Validations::InclusionValidator) &&
56
56
  validator.attributes.include?(column.name.to_sym) &&
57
- !validator.options.fetch(:in, []).include?(nil)
57
+ !validator_items.include?(nil)
58
58
  end
59
59
  end
60
60
 
61
61
  def exclusion_validator_present?(model, column)
62
62
  model.validators.any? do |validator|
63
+ validator_items = inclusion_validator_items(validator)
64
+ return true if validator_items.is_a?(Proc)
65
+
63
66
  validator.is_a?(ActiveModel::Validations::ExclusionValidator) &&
64
67
  validator.attributes.include?(column.name.to_sym) &&
65
- validator.options.fetch(:in, []).include?(nil)
68
+ validator_items.include?(nil)
66
69
  end
67
70
  end
68
71
 
@@ -79,6 +82,10 @@ module ActiveRecordDoctor
79
82
  (validator.attributes & allowed_attributes).present?
80
83
  end
81
84
  end
85
+
86
+ def inclusion_validator_items(validator)
87
+ validator.options[:in] || validator.options[:within] || []
88
+ end
82
89
  end
83
90
  end
84
91
  end
@@ -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
@@ -35,57 +38,69 @@ module ActiveRecordDoctor
35
38
  end
36
39
 
37
40
  def validations_without_indexes
38
- models(except: config(:ignore_models)).each do |model|
39
- next unless model.table_exists?
40
-
41
- model.validators.each do |validator|
41
+ each_model(except: config(:ignore_models), existing_tables_only: true) do |model|
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|
42
46
  scope = Array(validator.options.fetch(:scope, []))
43
47
 
44
48
  next unless validator.is_a?(ActiveRecord::Validations::UniquenessValidator)
45
- 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?
46
60
 
47
61
  validator.attributes.each do |attribute|
48
62
  columns = resolve_attributes(model, scope + [attribute])
49
63
 
50
- next if unique_index?(model.table_name, columns)
51
64
  next if ignore_columns.include?("#{model.name}(#{columns.join(',')})")
52
65
 
53
- 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
54
76
  end
55
77
  end
56
78
  end
57
79
  end
58
80
 
59
81
  def has_ones_without_indexes # rubocop:disable Naming/PredicateName
60
- models.each do |model|
61
- has_ones = model.reflect_on_all_associations(:has_one)
62
- has_ones.each do |has_one|
63
- next if has_one.is_a?(ActiveRecord::Reflection::ThroughReflection) || has_one.scope
64
-
65
- association_model = has_one.klass
66
- next if config(:ignore_models).include?(association_model.name)
67
-
68
- foreign_key = has_one.foreign_key
69
- next if ignore_columns.include?(foreign_key.to_s)
70
-
71
- table_name = association_model.table_name
72
- next if unique_index?(table_name, [foreign_key])
73
-
74
- problem!(model: model, table: table_name, columns: [foreign_key], problem: :has_ones)
82
+ each_model do |model|
83
+ each_association(model, type: :has_one, has_scope: false, through: false) do |has_one|
84
+ next if config(:ignore_models).include?(has_one.klass.name)
85
+
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(',')})")
93
+
94
+ table_name = has_one.klass.table_name
95
+ next if unique_index?(table_name, columns)
96
+
97
+ problem!(model: model, table: table_name, columns: columns, problem: :has_ones)
75
98
  end
76
99
  end
77
100
  end
78
101
 
79
- def supported_validator?(validator)
80
- validator.options[:if].nil? &&
81
- validator.options[:unless].nil? &&
82
- validator.options[:conditions].nil? &&
83
-
84
- # In Rails 6, default option values are no longer explicitly set on
85
- # options so if the key is absent we must fetch the default value
86
- # ourselves. case_sensitive is the default in 4.2+ so it's safe to
87
- # put true literally.
88
- validator.options.fetch(:case_sensitive, true)
102
+ def conditional_validator?(validator)
103
+ (validator.options.keys & [:if, :unless, :conditions]).present?
89
104
  end
90
105
 
91
106
  def resolve_attributes(model, attributes)
@@ -105,9 +120,17 @@ module ActiveRecordDoctor
105
120
  def unique_index?(table_name, columns, scope = nil)
106
121
  columns = (Array(scope) + columns).map(&:to_s)
107
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
+
108
131
  index.unique &&
109
132
  index.where.nil? &&
110
- (Array(index.columns) - columns).empty?
133
+ (index_columns - columns).empty?
111
134
  end
112
135
  end
113
136
 
@@ -116,6 +139,18 @@ module ActiveRecordDoctor
116
139
  column.gsub(" ", "")
117
140
  end
118
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
119
154
  end
120
155
  end
121
156
  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) || 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
@@ -20,8 +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_exists? || 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)
25
25
 
26
26
  problem!(model: model.name, table: model.table_name)
27
27
  end
@@ -31,20 +31,12 @@ module ActiveRecordDoctor
31
31
  end
32
32
 
33
33
  def detect
34
- tables(except: config(:ignore_tables)).each do |table|
35
- timestamp_columns = connection.columns(table).reject do |column|
36
- config(:ignore_columns).include?("#{table}.#{column.name}")
37
- end.select do |column|
38
- config(:column_names).include?(column.name)
39
- end
40
-
41
- next if timestamp_columns.empty?
42
-
43
- timestamp_columns.each do |timestamp_column|
44
- indexes(table, except: config(:ignore_indexes)).each do |index|
45
- next if index.where =~ /\b#{timestamp_column.name}\s+IS\s+(NOT\s+)?NULL\b/i
34
+ each_table(except: config(:ignore_tables)) do |table|
35
+ each_column(table, only: config(:column_names), except: config(:ignore_columns)) do |column|
36
+ each_index(table, except: config(:ignore_indexes)) do |index|
37
+ next if index.where =~ /\b#{column.name}\s+IS\s+(NOT\s+)?NULL\b/i
46
38
 
47
- problem!(index: index.name, column_name: timestamp_column.name)
39
+ problem!(index: index.name, column_name: column.name)
48
40
  end
49
41
  end
50
42
  end
@@ -18,30 +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
- tables(except: config(:ignore_tables)).each do |table|
29
- connection.columns(table).each do |column|
30
- next if config(:ignore_columns).include?("#{table}.#{column.name}")
31
-
32
- next unless foreign_key?(column)
28
+ each_table(except: config(:ignore_tables)) do |table|
29
+ each_column(table, except: config(:ignore_columns)) do |column|
30
+ next unless named_like_foreign_key?(column) || foreign_key?(table, column)
33
31
  next if indexed?(table, column)
34
32
  next if indexed_as_polymorphic?(table, column)
35
33
 
36
- 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)
37
44
  end
38
45
  end
39
46
  end
40
47
 
41
- def foreign_key?(column)
48
+ def named_like_foreign_key?(column)
42
49
  column.name.end_with?("_id")
43
50
  end
44
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
+
45
58
  def indexed?(table, column)
46
59
  connection.indexes(table).any? do |index|
47
60
  index.columns.first == column.name
@@ -49,9 +62,20 @@ module ActiveRecordDoctor
49
62
  end
50
63
 
51
64
  def indexed_as_polymorphic?(table, column)
52
- type_column_name = column.name.sub(/_id\Z/, "_type")
53
65
  connection.indexes(table).any? do |index|
54
- 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"
55
79
  end
56
80
  end
57
81
  end