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.
- checksums.yaml +4 -4
- data/README.md +15 -15
- data/lib/active_record_doctor/detectors/base.rb +194 -53
- data/lib/active_record_doctor/detectors/extraneous_indexes.rb +36 -34
- data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +2 -5
- data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +87 -37
- data/lib/active_record_doctor/detectors/incorrect_length_validation.rb +7 -10
- data/lib/active_record_doctor/detectors/mismatched_foreign_key_type.rb +16 -9
- data/lib/active_record_doctor/detectors/missing_foreign_keys.rb +2 -4
- data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +13 -11
- data/lib/active_record_doctor/detectors/missing_presence_validation.rb +14 -7
- data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +70 -35
- data/lib/active_record_doctor/detectors/short_primary_key_type.rb +4 -4
- data/lib/active_record_doctor/detectors/undefined_table_references.rb +2 -2
- data/lib/active_record_doctor/detectors/unindexed_deleted_at.rb +5 -13
- data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +35 -11
- data/lib/active_record_doctor/logger/dummy.rb +11 -0
- data/lib/active_record_doctor/logger/hierarchical.rb +22 -0
- data/lib/active_record_doctor/logger.rb +6 -0
- data/lib/active_record_doctor/rake/task.rb +10 -1
- data/lib/active_record_doctor/runner.rb +8 -3
- data/lib/active_record_doctor/utils.rb +21 -0
- data/lib/active_record_doctor/version.rb +1 -1
- data/lib/active_record_doctor.rb +5 -0
- data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +14 -14
- data/test/active_record_doctor/detectors/disable_test.rb +1 -1
- data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +59 -6
- data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +7 -7
- data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +175 -57
- data/test/active_record_doctor/detectors/incorrect_length_validation_test.rb +16 -14
- data/test/active_record_doctor/detectors/mismatched_foreign_key_type_test.rb +35 -1
- data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +46 -23
- data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +55 -27
- data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +216 -47
- data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +5 -0
- data/test/active_record_doctor/detectors/undefined_table_references_test.rb +11 -13
- data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +39 -1
- data/test/active_record_doctor/runner_test.rb +18 -19
- data/test/generators/active_record_doctor/add_indexes/add_indexes_generator_test.rb +16 -6
- data/test/setup.rb +10 -6
- metadata +23 -7
- 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:,
|
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} -
|
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} -
|
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
|
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
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
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
|
-
|
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
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
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
|
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
|
-
|
35
|
-
|
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 &&
|
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(
|
21
|
+
def message(from_table:, from_column:, from_type:, to_table:, to_column:, to_type:)
|
22
22
|
# rubocop:disable Layout/LineLength
|
23
|
-
"#{
|
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
|
-
|
29
|
-
|
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
|
-
|
36
|
-
|
37
|
-
next if from_column.sql_type ==
|
38
|
-
|
39
|
-
problem!(
|
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
|
-
|
27
|
-
|
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.
|
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
|
-
|
27
|
-
|
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
|
-
!
|
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
|
-
|
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
|
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.
|
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
|
-
|
39
|
-
|
40
|
-
|
41
|
-
model.validators
|
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
|
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
|
-
|
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
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
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
|
80
|
-
validator.options[:if].
|
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
|
-
(
|
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
|
-
|
23
|
+
each_table(except: config(:ignore_tables)) do |table|
|
24
24
|
column = primary_key(table)
|
25
25
|
next if column.nil?
|
26
|
-
next if
|
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
|
41
|
-
column.
|
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
|
-
|
24
|
-
next if
|
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
|
-
|
35
|
-
|
36
|
-
config(:
|
37
|
-
|
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:
|
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:,
|
21
|
+
def message(table:, columns:)
|
22
22
|
# rubocop:disable Layout/LineLength
|
23
|
-
"add an index on #{table}
|
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
|
-
|
29
|
-
|
30
|
-
next
|
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
|
-
|
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
|
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
|