active_record_doctor 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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