active_record_doctor 1.8.0 → 1.10.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 +316 -54
- data/lib/active_record_doctor/config/default.rb +76 -0
- data/lib/active_record_doctor/config/loader.rb +137 -0
- data/lib/active_record_doctor/config.rb +14 -0
- data/lib/active_record_doctor/detectors/base.rb +142 -21
- data/lib/active_record_doctor/detectors/extraneous_indexes.rb +59 -48
- data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +31 -23
- data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +102 -35
- data/lib/active_record_doctor/detectors/incorrect_length_validation.rb +63 -0
- data/lib/active_record_doctor/detectors/mismatched_foreign_key_type.rb +45 -0
- data/lib/active_record_doctor/detectors/missing_foreign_keys.rb +32 -23
- data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +41 -28
- data/lib/active_record_doctor/detectors/missing_presence_validation.rb +29 -23
- data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +92 -32
- data/lib/active_record_doctor/detectors/short_primary_key_type.rb +45 -0
- data/lib/active_record_doctor/detectors/undefined_table_references.rb +17 -20
- data/lib/active_record_doctor/detectors/unindexed_deleted_at.rb +43 -18
- data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +31 -20
- data/lib/active_record_doctor/detectors.rb +12 -4
- data/lib/active_record_doctor/errors.rb +226 -0
- data/lib/active_record_doctor/help.rb +39 -0
- data/lib/active_record_doctor/rake/task.rb +78 -0
- data/lib/active_record_doctor/runner.rb +41 -0
- data/lib/active_record_doctor/version.rb +1 -1
- data/lib/active_record_doctor.rb +8 -3
- data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +34 -21
- data/lib/tasks/active_record_doctor.rake +9 -18
- data/test/active_record_doctor/config/loader_test.rb +120 -0
- data/test/active_record_doctor/config_test.rb +116 -0
- data/test/active_record_doctor/detectors/disable_test.rb +30 -0
- data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +165 -8
- data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +48 -5
- data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +288 -12
- data/test/active_record_doctor/detectors/incorrect_length_validation_test.rb +105 -0
- data/test/active_record_doctor/detectors/mismatched_foreign_key_type_test.rb +82 -0
- data/test/active_record_doctor/detectors/missing_foreign_keys_test.rb +50 -4
- data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +172 -24
- data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +111 -14
- data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +223 -10
- data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +72 -0
- data/test/active_record_doctor/detectors/undefined_table_references_test.rb +34 -21
- data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +118 -8
- data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +56 -4
- data/test/active_record_doctor/runner_test.rb +42 -0
- data/test/generators/active_record_doctor/add_indexes/add_indexes_generator_test.rb +131 -0
- data/test/model_factory.rb +73 -23
- data/test/setup.rb +65 -71
- metadata +43 -7
- data/lib/active_record_doctor/printers/io_printer.rb +0 -133
- data/lib/active_record_doctor/task.rb +0 -28
- data/test/active_record_doctor/printers/io_printer_test.rb +0 -33
@@ -4,55 +4,110 @@ require "active_record_doctor/detectors/base"
|
|
4
4
|
|
5
5
|
module ActiveRecordDoctor
|
6
6
|
module Detectors
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
model.table_name.nil?
|
19
|
-
end.map do |model|
|
20
|
-
[
|
21
|
-
model.name,
|
22
|
-
associations_with_incorrect_dependent_options(model)
|
23
|
-
]
|
24
|
-
end.reject do |_model_name, associations|
|
25
|
-
associations.empty?
|
26
|
-
end))
|
27
|
-
end
|
7
|
+
class IncorrectDependentOption < Base # :nodoc:
|
8
|
+
@description = "detect associations with incorrect dependent options"
|
9
|
+
@config = {
|
10
|
+
ignore_models: {
|
11
|
+
description: "models whose associations should not be checked",
|
12
|
+
global: true
|
13
|
+
},
|
14
|
+
ignore_associations: {
|
15
|
+
description: "associations, written as Model.association, that should not be checked"
|
16
|
+
}
|
17
|
+
}
|
28
18
|
|
29
19
|
private
|
30
20
|
|
31
|
-
def
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
21
|
+
def message(model:, association:, problem:, associated_models:)
|
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
|
+
# rubocop:disable Layout/LineLength
|
32
|
+
case problem
|
33
|
+
when :suggest_destroy
|
34
|
+
"use `dependent: :destroy` or similar on #{model}.#{association} - the associated #{models_part} callbacks that are currently skipped"
|
35
|
+
when :suggest_delete
|
36
|
+
"use `dependent: :delete` or similar on #{model}.#{association} - the associated #{models_part} no callbacks and can be deleted without loading"
|
37
|
+
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"
|
39
|
+
end
|
40
|
+
# rubocop:enable Layout/LineLength
|
41
|
+
end
|
42
|
+
|
43
|
+
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}")
|
53
|
+
|
54
|
+
associated_models =
|
55
|
+
if association.polymorphic?
|
56
|
+
models_having(as: association.name)
|
57
|
+
else
|
58
|
+
[association.klass]
|
40
59
|
end
|
41
60
|
|
42
|
-
|
43
|
-
|
44
|
-
|
61
|
+
deletable_models, destroyable_models = associated_models.partition { |klass| deletable?(klass) }
|
62
|
+
|
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))
|
76
|
+
end
|
45
77
|
end
|
46
|
-
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def models_having(as:)
|
82
|
+
models.select do |model|
|
83
|
+
associations = model.reflect_on_all_associations(:has_one) +
|
84
|
+
model.reflect_on_all_associations(:has_many)
|
85
|
+
|
86
|
+
associations.any? do |association|
|
87
|
+
association.options[:as] == as
|
88
|
+
end
|
89
|
+
end
|
47
90
|
end
|
48
91
|
|
49
92
|
def callback_action(reflection)
|
50
93
|
case reflection.options[:dependent]
|
51
|
-
when :delete_all then :skip
|
94
|
+
when :delete, :delete_all then :skip
|
52
95
|
when :destroy then :invoke
|
53
96
|
end
|
54
97
|
end
|
55
98
|
|
99
|
+
def deletable?(model)
|
100
|
+
!defines_destroy_callbacks?(model) &&
|
101
|
+
dependent_models(model).all? do |dependent_model|
|
102
|
+
foreign_key = foreign_key(dependent_model.table_name, model.table_name)
|
103
|
+
|
104
|
+
foreign_key.nil? ||
|
105
|
+
foreign_key.on_delete == :nullify || (
|
106
|
+
foreign_key.on_delete == :cascade && deletable?(dependent_model)
|
107
|
+
)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
56
111
|
def defines_destroy_callbacks?(model)
|
57
112
|
# Destroying an associated model involves loading it first hence
|
58
113
|
# initialize and find are present. If they are defined on the model
|
@@ -66,6 +121,18 @@ module ActiveRecordDoctor
|
|
66
121
|
model._commit_callbacks.present? ||
|
67
122
|
model._rollback_callbacks.present?
|
68
123
|
end
|
124
|
+
|
125
|
+
def dependent_models(model)
|
126
|
+
reflections = model.reflect_on_all_associations(:has_many) +
|
127
|
+
model.reflect_on_all_associations(:has_one)
|
128
|
+
reflections.map(&:klass)
|
129
|
+
end
|
130
|
+
|
131
|
+
def foreign_key(from_table, to_table)
|
132
|
+
connection.foreign_keys(from_table).find do |foreign_key|
|
133
|
+
foreign_key.to_table == to_table
|
134
|
+
end
|
135
|
+
end
|
69
136
|
end
|
70
137
|
end
|
71
138
|
end
|
@@ -0,0 +1,63 @@
|
|
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
|
+
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)
|
42
|
+
next if model_maximum == column.limit
|
43
|
+
|
44
|
+
problem!(
|
45
|
+
model: model.name,
|
46
|
+
attribute: column.name,
|
47
|
+
table: model.table_name,
|
48
|
+
database_maximum: column.limit,
|
49
|
+
model_maximum: model_maximum
|
50
|
+
)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def maximum_allowed_by_validations(model)
|
56
|
+
length_validator = model.validators.find do |validator|
|
57
|
+
validator.kind == :length && validator.options.include?(:maximum)
|
58
|
+
end
|
59
|
+
length_validator ? length_validator.options[:maximum] : nil
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record_doctor/detectors/base"
|
4
|
+
|
5
|
+
module ActiveRecordDoctor
|
6
|
+
module Detectors
|
7
|
+
class MismatchedForeignKeyType < Base # :nodoc:
|
8
|
+
@description = "detect foreign key type mismatches"
|
9
|
+
@config = {
|
10
|
+
ignore_tables: {
|
11
|
+
description: "tables whose foreign keys should not be checked",
|
12
|
+
global: true
|
13
|
+
},
|
14
|
+
ignore_columns: {
|
15
|
+
description: "foreign keys, written as table.column, that should not be checked"
|
16
|
+
}
|
17
|
+
}
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def message(table:, column:)
|
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"
|
24
|
+
# rubocop:enable Layout/LineLength
|
25
|
+
end
|
26
|
+
|
27
|
+
def detect
|
28
|
+
tables(except: config(:ignore_tables)).each do |table|
|
29
|
+
connection.foreign_keys(table).each do |foreign_key|
|
30
|
+
from_column = column(table, foreign_key.column)
|
31
|
+
|
32
|
+
next if config(:ignore_columns).include?("#{table}.#{from_column.name}")
|
33
|
+
|
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)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -4,32 +4,41 @@ require "active_record_doctor/detectors/base"
|
|
4
4
|
|
5
5
|
module ActiveRecordDoctor
|
6
6
|
module Detectors
|
7
|
-
|
8
|
-
|
9
|
-
@
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
# We need to skip polymorphic associations as they can reference
|
19
|
-
# multiple tables but a foreign key constraint can reference
|
20
|
-
# a single predefined table.
|
21
|
-
named_like_foreign_key?(column) &&
|
22
|
-
!foreign_key?(table, column) &&
|
23
|
-
!polymorphic_foreign_key?(table, column)
|
24
|
-
end.map(&:name)
|
25
|
-
]
|
26
|
-
end.reject do |_table, columns|
|
27
|
-
columns.empty?
|
28
|
-
end))
|
29
|
-
end
|
7
|
+
class MissingForeignKeys < Base # :nodoc:
|
8
|
+
@description = "detect foreign-key-like columns lacking an actual foreign key constraint"
|
9
|
+
@config = {
|
10
|
+
ignore_tables: {
|
11
|
+
description: "tables whose columns should not be checked",
|
12
|
+
global: true
|
13
|
+
},
|
14
|
+
ignore_columns: {
|
15
|
+
description: "columns, written as table.column, that should not be checked"
|
16
|
+
}
|
17
|
+
}
|
30
18
|
|
31
19
|
private
|
32
20
|
|
21
|
+
def message(table:, column:)
|
22
|
+
"create a foreign key on #{table}.#{column} - looks like an association without a foreign key constraint"
|
23
|
+
end
|
24
|
+
|
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
|
+
|
30
|
+
# We need to skip polymorphic associations as they can reference
|
31
|
+
# multiple tables but a foreign key constraint can reference
|
32
|
+
# a single predefined table.
|
33
|
+
next unless named_like_foreign_key?(column)
|
34
|
+
next if foreign_key?(table, column)
|
35
|
+
next if polymorphic_foreign_key?(table, column)
|
36
|
+
|
37
|
+
problem!(table: table, column: column.name)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
33
42
|
def named_like_foreign_key?(column)
|
34
43
|
column.name.end_with?("_id")
|
35
44
|
end
|
@@ -4,39 +4,52 @@ require "active_record_doctor/detectors/base"
|
|
4
4
|
|
5
5
|
module ActiveRecordDoctor
|
6
6
|
module Detectors
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
!table_exists?(model.table_name)
|
19
|
-
end.map do |model|
|
20
|
-
[
|
21
|
-
model.table_name,
|
22
|
-
connection.columns(model.table_name).select do |column|
|
23
|
-
validator_needed?(model, column) &&
|
24
|
-
has_mandatory_presence_validator?(model, column) &&
|
25
|
-
column.null
|
26
|
-
end.map(&:name)
|
27
|
-
]
|
28
|
-
end.reject do |_model_name, columns|
|
29
|
-
columns.empty?
|
30
|
-
end))
|
31
|
-
end
|
7
|
+
class MissingNonNullConstraint < Base # :nodoc:
|
8
|
+
@description = "detect columns whose presence is always validated but isn't enforced via a non-NULL constraint"
|
9
|
+
@config = {
|
10
|
+
ignore_tables: {
|
11
|
+
description: "tables whose columns should not be checked",
|
12
|
+
global: true
|
13
|
+
},
|
14
|
+
ignore_columns: {
|
15
|
+
description: "columns, written as table.column, that should not be checked"
|
16
|
+
}
|
17
|
+
}
|
32
18
|
|
33
19
|
private
|
34
20
|
|
35
|
-
def
|
36
|
-
|
21
|
+
def message(column:, table:)
|
22
|
+
"add `NOT NULL` to #{table}.#{column} - models validates its presence but it's not non-NULL in the database"
|
23
|
+
end
|
24
|
+
|
25
|
+
def detect
|
26
|
+
table_models = models.group_by(&:table_name)
|
27
|
+
table_models.delete_if { |_table, models| !models.first.table_exists? }
|
28
|
+
|
29
|
+
table_models.each do |table, models|
|
30
|
+
next if config(:ignore_tables).include?(table)
|
31
|
+
|
32
|
+
concrete_models = models.reject do |model|
|
33
|
+
model.abstract_class? || sti_base_model?(model)
|
34
|
+
end
|
35
|
+
|
36
|
+
connection.columns(table).each do |column|
|
37
|
+
next if config(:ignore_columns).include?("#{table}.#{column.name}")
|
38
|
+
next if !column.null
|
39
|
+
next if !concrete_models.all? { |model| non_null_needed?(model, column) }
|
40
|
+
next if not_null_check_constraint_exists?(table, column)
|
41
|
+
|
42
|
+
problem!(column: column.name, table: table)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def sti_base_model?(model)
|
48
|
+
model.base_class == model &&
|
49
|
+
model.columns_hash.include?(model.inheritance_column.to_s)
|
37
50
|
end
|
38
51
|
|
39
|
-
def
|
52
|
+
def non_null_needed?(model, column)
|
40
53
|
# A foreign key can be validates via the column name (e.g. company_id)
|
41
54
|
# or the association name (e.g. company). We collect the allowed names
|
42
55
|
# in an array to check for their presence in the validator definition
|
@@ -4,35 +4,41 @@ require "active_record_doctor/detectors/base"
|
|
4
4
|
|
5
5
|
module ActiveRecordDoctor
|
6
6
|
module Detectors
|
7
|
-
|
8
|
-
|
9
|
-
@
|
7
|
+
class MissingPresenceValidation < Base # :nodoc:
|
8
|
+
@description = "detect non-NULL columns without a corresponding presence validator"
|
9
|
+
@config = {
|
10
|
+
ignore_models: {
|
11
|
+
description: "models whose underlying tables' columns should not be checked",
|
12
|
+
global: true
|
13
|
+
},
|
14
|
+
ignore_attributes: {
|
15
|
+
description: "specific attributes, written as Model.attribute, that should not be checked"
|
16
|
+
}
|
17
|
+
}
|
10
18
|
|
11
|
-
|
12
|
-
eager_load!
|
19
|
+
private
|
13
20
|
|
14
|
-
|
15
|
-
|
16
|
-
model.table_name == "schema_migrations" ||
|
17
|
-
!table_exists?(model.table_name)
|
18
|
-
end.map do |model|
|
19
|
-
[
|
20
|
-
model.name,
|
21
|
-
connection.columns(model.table_name).select do |column|
|
22
|
-
validator_needed?(model, column) &&
|
23
|
-
!validator_present?(model, column)
|
24
|
-
end.map(&:name)
|
25
|
-
]
|
26
|
-
end.reject do |_model_name, columns|
|
27
|
-
columns.empty?
|
28
|
-
end))
|
21
|
+
def message(column:, model:)
|
22
|
+
"add a `presence` validator to #{model}.#{column} - it's NOT NULL but lacks a validator"
|
29
23
|
end
|
30
24
|
|
31
|
-
|
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|
|
30
|
+
next unless validator_needed?(model, column)
|
31
|
+
next if validator_present?(model, column)
|
32
|
+
next if config(:ignore_attributes).include?("#{model}.#{column.name}")
|
33
|
+
|
34
|
+
problem!(column: column.name, model: model.name)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
32
38
|
|
33
39
|
def validator_needed?(model, column)
|
34
|
-
![model.primary_key, "created_at", "updated_at"].include?(column.name) &&
|
35
|
-
!column.null
|
40
|
+
![model.primary_key, "created_at", "updated_at", "created_on", "updated_on"].include?(column.name) &&
|
41
|
+
(!column.null || not_null_check_constraint_exists?(model.table_name, column))
|
36
42
|
end
|
37
43
|
|
38
44
|
def validator_present?(model, column)
|
@@ -4,38 +4,77 @@ require "active_record_doctor/detectors/base"
|
|
4
4
|
|
5
5
|
module ActiveRecordDoctor
|
6
6
|
module Detectors
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
7
|
+
class MissingUniqueIndexes < Base # :nodoc:
|
8
|
+
@description = "detect uniqueness validators not backed by a database constraint"
|
9
|
+
@config = {
|
10
|
+
ignore_models: {
|
11
|
+
description: "models whose uniqueness validators should not be checked",
|
12
|
+
global: true
|
13
|
+
},
|
14
|
+
ignore_columns: {
|
15
|
+
description: "specific validators, written as Model(column1, column2, ...), that should not be checked"
|
16
|
+
}
|
17
|
+
}
|
18
|
+
|
19
|
+
private
|
20
|
+
|
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
|
29
|
+
end
|
30
|
+
# rubocop:enable Layout/LineLength
|
31
|
+
|
32
|
+
def detect
|
33
|
+
validations_without_indexes
|
34
|
+
has_ones_without_indexes
|
35
|
+
end
|
36
|
+
|
37
|
+
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|
|
42
|
+
scope = Array(validator.options.fetch(:scope, []))
|
43
|
+
|
44
|
+
next unless validator.is_a?(ActiveRecord::Validations::UniquenessValidator)
|
45
|
+
next unless supported_validator?(validator)
|
46
|
+
|
47
|
+
validator.attributes.each do |attribute|
|
48
|
+
columns = resolve_attributes(model, scope + [attribute])
|
49
|
+
|
50
|
+
next if unique_index?(model.table_name, columns)
|
51
|
+
next if ignore_columns.include?("#{model.name}(#{columns.join(',')})")
|
52
|
+
|
53
|
+
problem!(model: model, table: model.table_name, columns: columns, problem: :validations)
|
31
54
|
end
|
32
|
-
|
33
|
-
end
|
34
|
-
indexes.empty?
|
35
|
-
end))
|
55
|
+
end
|
56
|
+
end
|
36
57
|
end
|
37
58
|
|
38
|
-
|
59
|
+
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)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
39
78
|
|
40
79
|
def supported_validator?(validator)
|
41
80
|
validator.options[:if].nil? &&
|
@@ -49,11 +88,32 @@ module ActiveRecordDoctor
|
|
49
88
|
validator.options.fetch(:case_sensitive, true)
|
50
89
|
end
|
51
90
|
|
52
|
-
def
|
53
|
-
|
91
|
+
def resolve_attributes(model, attributes)
|
92
|
+
attributes.flat_map do |attribute|
|
93
|
+
reflection = model.reflect_on_association(attribute)
|
94
|
+
|
95
|
+
if reflection.nil?
|
96
|
+
attribute
|
97
|
+
elsif reflection.polymorphic?
|
98
|
+
[reflection.foreign_type, reflection.foreign_key]
|
99
|
+
else
|
100
|
+
reflection.foreign_key
|
101
|
+
end
|
102
|
+
end.map(&:to_s)
|
103
|
+
end
|
54
104
|
|
105
|
+
def unique_index?(table_name, columns, scope = nil)
|
106
|
+
columns = (Array(scope) + columns).map(&:to_s)
|
55
107
|
indexes(table_name).any? do |index|
|
56
|
-
index.
|
108
|
+
index.unique &&
|
109
|
+
index.where.nil? &&
|
110
|
+
(Array(index.columns) - columns).empty?
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def ignore_columns
|
115
|
+
@ignore_columns ||= config(:ignore_columns).map do |column|
|
116
|
+
column.gsub(" ", "")
|
57
117
|
end
|
58
118
|
end
|
59
119
|
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record_doctor/detectors/base"
|
4
|
+
|
5
|
+
module ActiveRecordDoctor
|
6
|
+
module Detectors
|
7
|
+
class ShortPrimaryKeyType < Base # :nodoc:
|
8
|
+
@description = "detect primary keys with short integer types"
|
9
|
+
@config = {
|
10
|
+
ignore_tables: {
|
11
|
+
description: "tables whose primary keys should not be checked",
|
12
|
+
global: true
|
13
|
+
}
|
14
|
+
}
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def message(table:, column:)
|
19
|
+
"change the type of #{table}.#{column} to bigint"
|
20
|
+
end
|
21
|
+
|
22
|
+
def detect
|
23
|
+
tables(except: config(:ignore_tables)).each do |table|
|
24
|
+
column = primary_key(table)
|
25
|
+
next if column.nil?
|
26
|
+
next if bigint?(column) || uuid?(column)
|
27
|
+
|
28
|
+
problem!(table: table, column: column.name)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def bigint?(column)
|
33
|
+
if column.respond_to?(:bigint?)
|
34
|
+
column.bigint?
|
35
|
+
else
|
36
|
+
/\Abigint\b/.match?(column.sql_type)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def uuid?(column)
|
41
|
+
column.sql_type == "uuid"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|