active_record_doctor 1.8.0 → 1.9.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +246 -48
- data/lib/active_record_doctor/config/default.rb +59 -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 +110 -19
- data/lib/active_record_doctor/detectors/extraneous_indexes.rb +63 -37
- data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +32 -23
- data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +70 -34
- 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 +40 -28
- data/lib/active_record_doctor/detectors/missing_presence_validation.rb +28 -21
- data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +40 -30
- data/lib/active_record_doctor/detectors/short_primary_key_type.rb +41 -0
- data/lib/active_record_doctor/detectors/undefined_table_references.rb +19 -20
- data/lib/active_record_doctor/detectors/unindexed_deleted_at.rb +44 -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 +7 -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/extraneous_indexes_test.rb +131 -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 +190 -12
- 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 +138 -24
- data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +74 -13
- data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +57 -8
- data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +64 -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 +112 -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 +62 -72
- metadata +40 -9
- 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
@@ -5,33 +5,126 @@ module ActiveRecordDoctor
|
|
5
5
|
# Base class for all active_record_doctor detectors.
|
6
6
|
class Base
|
7
7
|
class << self
|
8
|
-
attr_reader :description
|
8
|
+
attr_reader :description, :config
|
9
9
|
|
10
|
-
def run
|
11
|
-
new.run
|
10
|
+
def run(config, io)
|
11
|
+
new(config, io).run
|
12
12
|
end
|
13
|
+
|
14
|
+
def underscored_name
|
15
|
+
name.demodulize.underscore.to_sym
|
16
|
+
end
|
17
|
+
|
18
|
+
def locals_and_globals
|
19
|
+
locals = []
|
20
|
+
globals = []
|
21
|
+
|
22
|
+
config.each do |key, metadata|
|
23
|
+
locals << key
|
24
|
+
globals << key if metadata[:global]
|
25
|
+
end
|
26
|
+
|
27
|
+
[locals, globals]
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def initialize(config, io)
|
32
|
+
@problems = []
|
33
|
+
@config = config
|
34
|
+
@io = io
|
35
|
+
end
|
36
|
+
|
37
|
+
def run
|
38
|
+
@problems = []
|
39
|
+
|
40
|
+
detect
|
41
|
+
@problems.each do |problem|
|
42
|
+
@io.puts(message(**problem))
|
43
|
+
end
|
44
|
+
|
45
|
+
success = @problems.empty?
|
46
|
+
@problems = nil
|
47
|
+
success
|
13
48
|
end
|
14
49
|
|
15
50
|
private
|
16
51
|
|
17
|
-
def
|
18
|
-
|
52
|
+
def config(key)
|
53
|
+
local = @config.detectors.fetch(underscored_name).fetch(key)
|
54
|
+
return local if !self.class.config.fetch(key)[:global]
|
55
|
+
|
56
|
+
global = @config.globals[key]
|
57
|
+
return local if global.nil?
|
58
|
+
|
59
|
+
# Right now, all globals are arrays so we can merge them here. Once
|
60
|
+
# we add non-array globals we'll need to support per-global merging.
|
61
|
+
Array.new(local).concat(global)
|
62
|
+
end
|
63
|
+
|
64
|
+
def detect
|
65
|
+
raise("#detect should be implemented by a subclass")
|
66
|
+
end
|
67
|
+
|
68
|
+
def message(**_attrs)
|
69
|
+
raise("#message should be implemented by a subclass")
|
70
|
+
end
|
71
|
+
|
72
|
+
def problem!(**attrs)
|
73
|
+
@problems << attrs
|
74
|
+
end
|
75
|
+
|
76
|
+
def warning(message)
|
77
|
+
puts(message)
|
19
78
|
end
|
20
79
|
|
21
80
|
def connection
|
22
81
|
@connection ||= ActiveRecord::Base.connection
|
23
82
|
end
|
24
83
|
|
25
|
-
def indexes(table_name)
|
26
|
-
connection.indexes(table_name)
|
84
|
+
def indexes(table_name, except: [])
|
85
|
+
connection.indexes(table_name).reject do |index|
|
86
|
+
except.include?(index.name)
|
87
|
+
end
|
27
88
|
end
|
28
89
|
|
29
|
-
def tables
|
30
|
-
|
90
|
+
def tables(except: [])
|
91
|
+
tables =
|
92
|
+
if ActiveRecord::VERSION::STRING >= "5.1"
|
93
|
+
connection.tables
|
94
|
+
else
|
95
|
+
connection.data_sources
|
96
|
+
end
|
97
|
+
|
98
|
+
tables.reject do |table|
|
99
|
+
except.include?(table)
|
100
|
+
end
|
31
101
|
end
|
32
102
|
|
33
103
|
def table_exists?(table_name)
|
34
|
-
|
104
|
+
if ActiveRecord::VERSION::STRING >= "5.1"
|
105
|
+
connection.table_exists?(table_name)
|
106
|
+
else
|
107
|
+
connection.data_source_exists?(table_name)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def tables_and_views
|
112
|
+
if connection.respond_to?(:data_sources)
|
113
|
+
connection.data_sources
|
114
|
+
else
|
115
|
+
connection.tables
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def primary_key(table_name)
|
120
|
+
primary_key_name = connection.primary_key(table_name)
|
121
|
+
return nil if primary_key_name.nil?
|
122
|
+
|
123
|
+
column(table_name, primary_key_name)
|
124
|
+
end
|
125
|
+
|
126
|
+
def column(table_name, column_name)
|
127
|
+
connection.columns(table_name).find { |column| column.name == column_name }
|
35
128
|
end
|
36
129
|
|
37
130
|
def views
|
@@ -42,22 +135,20 @@ module ActiveRecordDoctor
|
|
42
135
|
ActiveRecord::Base.connection.execute(<<-SQL).map { |tuple| tuple.fetch("relname") }
|
43
136
|
SELECT c.relname FROM pg_class c WHERE c.relkind IN ('m', 'v')
|
44
137
|
SQL
|
45
|
-
else
|
138
|
+
else
|
46
139
|
# We don't support this Rails/database combination yet.
|
47
140
|
nil
|
48
141
|
end
|
49
142
|
end
|
50
143
|
|
51
|
-
def
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
def eager_load!
|
56
|
-
Rails.application.eager_load!
|
144
|
+
def models(except: [])
|
145
|
+
ActiveRecord::Base.descendants.reject do |model|
|
146
|
+
model.name.start_with?("HABTM_") || except.include?(model.name)
|
147
|
+
end
|
57
148
|
end
|
58
149
|
|
59
|
-
def
|
60
|
-
|
150
|
+
def underscored_name
|
151
|
+
self.class.underscored_name
|
61
152
|
end
|
62
153
|
end
|
63
154
|
end
|
@@ -4,55 +4,60 @@ require "active_record_doctor/detectors/base"
|
|
4
4
|
|
5
5
|
module ActiveRecordDoctor
|
6
6
|
module Detectors
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
7
|
+
class ExtraneousIndexes < Base # :nodoc:
|
8
|
+
@description = "identify indexes that can be dropped without degrading performance"
|
9
|
+
@config = {
|
10
|
+
ignore_tables: {
|
11
|
+
description: "tables whose indexes should never be reported as extraneous",
|
12
|
+
global: true
|
13
|
+
},
|
14
|
+
ignore_indexes: {
|
15
|
+
description: "indexes that should never be reported as extraneous",
|
16
|
+
global: true
|
17
|
+
}
|
18
|
+
}
|
11
19
|
|
12
|
-
|
13
|
-
|
20
|
+
private
|
21
|
+
|
22
|
+
def message(extraneous_index:, replacement_indexes:)
|
23
|
+
if replacement_indexes.nil?
|
24
|
+
"remove #{extraneous_index} - coincides with the primary key on the table"
|
25
|
+
else
|
26
|
+
"remove #{extraneous_index} - can be replaced by #{replacement_indexes.join(' or ')}"
|
27
|
+
end
|
14
28
|
end
|
15
29
|
|
16
|
-
|
30
|
+
def detect
|
31
|
+
subindexes_of_multi_column_indexes
|
32
|
+
indexed_primary_keys
|
33
|
+
end
|
17
34
|
|
18
35
|
def subindexes_of_multi_column_indexes
|
19
|
-
tables.
|
20
|
-
table == "schema_migrations"
|
21
|
-
end.flat_map do |table|
|
36
|
+
tables(except: config(:ignore_tables)).each do |table|
|
22
37
|
indexes = indexes(table)
|
23
|
-
|
24
|
-
|
25
|
-
|
38
|
+
maximal_indexes = indexes.select { |index| maximal?(indexes, index) }
|
39
|
+
|
40
|
+
indexes.each do |index|
|
41
|
+
next if maximal_indexes.include?(index)
|
42
|
+
|
43
|
+
replacement_indexes = maximal_indexes.select do |maximum_index|
|
44
|
+
cover?(maximum_index, index)
|
45
|
+
end.map(&:name).sort
|
46
|
+
|
47
|
+
next if config(:ignore_indexes).include?(index.name)
|
26
48
|
|
27
|
-
|
28
|
-
maximum_indexes.include?(index)
|
29
|
-
end.map do |extraneous_index|
|
30
|
-
[
|
31
|
-
extraneous_index.name,
|
32
|
-
[
|
33
|
-
:multi_column,
|
34
|
-
maximum_indexes.select do |maximum_index|
|
35
|
-
cover?(maximum_index, extraneous_index)
|
36
|
-
end.map(&:name).sort
|
37
|
-
].flatten(1)
|
38
|
-
]
|
49
|
+
problem!(extraneous_index: index.name, replacement_indexes: replacement_indexes)
|
39
50
|
end
|
40
51
|
end
|
41
52
|
end
|
42
53
|
|
43
54
|
def indexed_primary_keys
|
44
|
-
|
45
|
-
table
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
index.columns == ["id"]
|
51
|
-
end
|
52
|
-
]
|
53
|
-
end.flat_map do |table, indexes|
|
54
|
-
indexes.map do |index|
|
55
|
-
[index.name, [:primary_key, table]]
|
55
|
+
tables(except: config(:ignore_tables)).each do |table|
|
56
|
+
indexes(table).each do |index|
|
57
|
+
next if config(:ignore_indexes).include?(index.name)
|
58
|
+
next if index.columns != ["id"]
|
59
|
+
|
60
|
+
problem!(extraneous_index: index.name, replacement_indexes: nil)
|
56
61
|
end
|
57
62
|
end
|
58
63
|
end
|
@@ -65,6 +70,8 @@ module ActiveRecordDoctor
|
|
65
70
|
|
66
71
|
# Does lhs cover rhs?
|
67
72
|
def cover?(lhs, rhs)
|
73
|
+
return false unless compatible_options?(lhs, rhs)
|
74
|
+
|
68
75
|
case [lhs.unique, rhs.unique]
|
69
76
|
when [true, true]
|
70
77
|
lhs.columns == rhs.columns
|
@@ -83,6 +90,25 @@ module ActiveRecordDoctor
|
|
83
90
|
def indexes(table_name)
|
84
91
|
super.select { |index| index.columns.is_a?(Array) }
|
85
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
|
86
112
|
end
|
87
113
|
end
|
88
114
|
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
|
-
end.map do |model|
|
19
|
-
[
|
20
|
-
model.name,
|
21
|
-
connection.columns(model.table_name).select do |column|
|
22
|
-
column.type == :boolean &&
|
23
|
-
has_presence_validator?(model, column)
|
24
|
-
end.map(&:name)
|
25
|
-
]
|
26
|
-
end.reject do |_model_name, columns|
|
27
|
-
columns.empty?
|
28
|
-
end))
|
29
|
-
end
|
7
|
+
class IncorrectBooleanPresenceValidation < Base # :nodoc:
|
8
|
+
@description = "detect persence (instead of inclusion) validators on boolean columns"
|
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
|
+
}
|
30
18
|
|
31
19
|
private
|
32
20
|
|
21
|
+
def message(model:, attribute:)
|
22
|
+
# rubocop:disable Layout/LineLength
|
23
|
+
"replace the `presence` validator on #{model}.#{attribute} with `inclusion` - `presence` can't be used on booleans"
|
24
|
+
# rubocop:enable Layout/LineLength
|
25
|
+
end
|
26
|
+
|
27
|
+
def detect
|
28
|
+
models(except: config(:ignore_models)).each do |model|
|
29
|
+
next if model.table_name.nil?
|
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}")
|
34
|
+
next unless column.type == :boolean
|
35
|
+
next unless has_presence_validator?(model, column)
|
36
|
+
|
37
|
+
problem!(model: model.name, attribute: column.name)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
33
42
|
def has_presence_validator?(model, column)
|
34
43
|
model.validators.any? do |validator|
|
35
44
|
validator.kind == :presence && validator.attributes.include?(column.name.to_sym)
|
@@ -4,55 +4,79 @@ require "active_record_doctor/detectors/base"
|
|
4
4
|
|
5
5
|
module ActiveRecordDoctor
|
6
6
|
module Detectors
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
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
|
+
}
|
13
18
|
|
14
|
-
|
15
|
-
eager_load!
|
19
|
+
private
|
16
20
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
end
|
21
|
+
def message(model:, association:, problem:)
|
22
|
+
# rubocop:disable Layout/LineLength
|
23
|
+
case problem
|
24
|
+
when :suggest_destroy
|
25
|
+
"use `dependent: :destroy` or similar on #{model}.#{association} - the associated model has callbacks that are currently skipped"
|
26
|
+
when :suggest_delete
|
27
|
+
"use `dependent: :delete` or similar on #{model}.#{association} - the associated model has no callbacks and can be deleted without loading"
|
28
|
+
when :suggest_delete_all
|
29
|
+
"use `dependent: :delete_all` or similar on #{model}.#{association} - associated models have no validations and can be deleted in bulk"
|
30
|
+
end
|
31
|
+
# rubocop:enable Layout/LineLength
|
27
32
|
end
|
28
33
|
|
29
|
-
|
34
|
+
def detect
|
35
|
+
models(except: config(:ignore_models)).each do |model|
|
36
|
+
next if model.table_name.nil?
|
37
|
+
|
38
|
+
associations = model.reflect_on_all_associations(:has_many) +
|
39
|
+
model.reflect_on_all_associations(:has_one) +
|
40
|
+
model.reflect_on_all_associations(:belongs_to)
|
30
41
|
|
31
|
-
|
32
|
-
|
33
|
-
reflections.map do |reflection|
|
34
|
-
if callback_action(reflection) == :invoke && !defines_destroy_callbacks?(reflection.klass)
|
35
|
-
suggestion =
|
36
|
-
case reflection.macro
|
37
|
-
when :has_many then :suggest_delete_all
|
38
|
-
when :has_one then :suggest_delete
|
39
|
-
else raise("unsupported association type #{reflection.macro}")
|
40
|
-
end
|
42
|
+
associations.each do |association|
|
43
|
+
next if config(:ignore_associations).include?("#{model.name}.#{association.name}")
|
41
44
|
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
+
if callback_action(association) == :invoke && deletable?(association.klass)
|
46
|
+
suggestion =
|
47
|
+
case association.macro
|
48
|
+
when :has_many then :suggest_delete_all
|
49
|
+
when :has_one, :belongs_to then :suggest_delete
|
50
|
+
else raise("unsupported association type #{association.macro}")
|
51
|
+
end
|
52
|
+
|
53
|
+
problem!(model: model.name, association: association.name, problem: suggestion)
|
54
|
+
elsif callback_action(association) == :skip && !deletable?(association.klass)
|
55
|
+
problem!(model: model.name, association: association.name, problem: :suggest_destroy)
|
56
|
+
end
|
45
57
|
end
|
46
|
-
end
|
58
|
+
end
|
47
59
|
end
|
48
60
|
|
49
61
|
def callback_action(reflection)
|
50
62
|
case reflection.options[:dependent]
|
51
|
-
when :delete_all then :skip
|
63
|
+
when :delete, :delete_all then :skip
|
52
64
|
when :destroy then :invoke
|
53
65
|
end
|
54
66
|
end
|
55
67
|
|
68
|
+
def deletable?(model)
|
69
|
+
!defines_destroy_callbacks?(model) &&
|
70
|
+
dependent_models(model).all? do |dependent_model|
|
71
|
+
foreign_key = foreign_key(dependent_model.table_name, model.table_name)
|
72
|
+
|
73
|
+
foreign_key.nil? ||
|
74
|
+
foreign_key.on_delete == :nullify || (
|
75
|
+
foreign_key.on_delete == :cascade && deletable?(dependent_model)
|
76
|
+
)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
56
80
|
def defines_destroy_callbacks?(model)
|
57
81
|
# Destroying an associated model involves loading it first hence
|
58
82
|
# initialize and find are present. If they are defined on the model
|
@@ -66,6 +90,18 @@ module ActiveRecordDoctor
|
|
66
90
|
model._commit_callbacks.present? ||
|
67
91
|
model._rollback_callbacks.present?
|
68
92
|
end
|
93
|
+
|
94
|
+
def dependent_models(model)
|
95
|
+
reflections = model.reflect_on_all_associations(:has_many) +
|
96
|
+
model.reflect_on_all_associations(:has_one)
|
97
|
+
reflections.map(&:klass)
|
98
|
+
end
|
99
|
+
|
100
|
+
def foreign_key(from_table, to_table)
|
101
|
+
connection.foreign_keys(from_table).find do |foreign_key|
|
102
|
+
foreign_key.to_table == to_table
|
103
|
+
end
|
104
|
+
end
|
69
105
|
end
|
70
106
|
end
|
71
107
|
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
|