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.
- checksums.yaml +4 -4
- data/README.md +83 -19
- data/lib/active_record_doctor/config/default.rb +17 -0
- data/lib/active_record_doctor/detectors/base.rb +216 -56
- data/lib/active_record_doctor/detectors/extraneous_indexes.rb +38 -56
- data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +2 -6
- data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +88 -15
- data/lib/active_record_doctor/detectors/incorrect_length_validation.rb +60 -0
- 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 +14 -11
- data/lib/active_record_doctor/detectors/missing_presence_validation.rb +16 -10
- data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +61 -17
- data/lib/active_record_doctor/detectors/short_primary_key_type.rb +6 -2
- data/lib/active_record_doctor/detectors/undefined_table_references.rb +2 -4
- data/lib/active_record_doctor/detectors/unindexed_deleted_at.rb +6 -15
- data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +2 -4
- 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/version.rb +1 -1
- data/lib/active_record_doctor.rb +4 -0
- data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +5 -5
- data/test/active_record_doctor/detectors/disable_test.rb +30 -0
- data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +34 -0
- 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 +220 -43
- data/test/active_record_doctor/detectors/incorrect_length_validation_test.rb +107 -0
- 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 +78 -21
- data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +89 -25
- data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +179 -15
- data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +27 -19
- data/test/active_record_doctor/detectors/undefined_table_references_test.rb +11 -13
- data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +9 -3
- data/test/active_record_doctor/runner_test.rb +18 -19
- data/test/setup.rb +15 -7
- metadata +25 -5
- 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
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
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
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
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
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
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 [
|
76
|
+
case [index1.unique, index2.unique]
|
76
77
|
when [true, true]
|
77
|
-
|
78
|
-
when [
|
78
|
+
(index2.columns - index1.columns).empty?
|
79
|
+
when [true, false]
|
79
80
|
false
|
80
81
|
else
|
81
|
-
prefix?(
|
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
|
-
|
29
|
-
|
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} -
|
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} -
|
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
|
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
|
-
|
36
|
-
|
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
|
-
|
39
|
-
|
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
|
-
|
43
|
-
|
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
|
-
|
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!(
|
54
|
-
|
55
|
-
|
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(
|
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| 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.
|
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
|
-
|
27
|
-
|
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
|
-
!
|
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
|
-
|
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
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
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
|
-
|
29
|
-
|
30
|
-
|
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
|
-
|
43
|
-
|
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
|
-
|
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
|
63
|
-
|
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.
|
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
|
-
|
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
|
-
|
24
|
-
next if model.table_name
|
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
|