active_record_doctor 1.7.1 → 1.9.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 +287 -43
- 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 +155 -0
- data/lib/active_record_doctor/detectors/extraneous_indexes.rb +114 -0
- data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +49 -0
- data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +107 -0
- data/lib/active_record_doctor/detectors/mismatched_foreign_key_type.rb +45 -0
- data/lib/active_record_doctor/detectors/missing_foreign_keys.rb +60 -0
- data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +72 -0
- data/lib/active_record_doctor/{tasks → detectors}/missing_presence_validation.rb +35 -21
- data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +71 -0
- data/lib/active_record_doctor/detectors/short_primary_key_type.rb +41 -0
- data/lib/active_record_doctor/detectors/undefined_table_references.rb +33 -0
- data/lib/active_record_doctor/detectors/unindexed_deleted_at.rb +55 -0
- data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +59 -0
- data/lib/active_record_doctor/detectors.rb +21 -0
- data/lib/active_record_doctor/errors.rb +226 -0
- data/lib/active_record_doctor/help.rb +39 -0
- data/lib/active_record_doctor/printers.rb +3 -1
- data/lib/active_record_doctor/railtie.rb +2 -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 +3 -1
- data/lib/active_record_doctor.rb +24 -2
- data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +46 -29
- data/lib/tasks/active_record_doctor.rake +21 -29
- 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 +190 -0
- data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +79 -0
- data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +295 -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 +70 -0
- data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +216 -0
- data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +168 -0
- data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +163 -0
- 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 +57 -0
- data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +171 -0
- data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +78 -0
- 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 +128 -0
- data/test/setup.rb +116 -0
- metadata +103 -154
- data/Rakefile +0 -28
- data/lib/active_record_doctor/printers/io_printer.rb +0 -105
- data/lib/active_record_doctor/tasks/base.rb +0 -78
- data/lib/active_record_doctor/tasks/extraneous_indexes.rb +0 -82
- data/lib/active_record_doctor/tasks/incorrect_boolean_presence_validation.rb +0 -33
- data/lib/active_record_doctor/tasks/missing_foreign_keys.rb +0 -46
- data/lib/active_record_doctor/tasks/missing_non_null_constraint.rb +0 -52
- data/lib/active_record_doctor/tasks/missing_unique_indexes.rb +0 -56
- data/lib/active_record_doctor/tasks/undefined_table_references.rb +0 -33
- data/lib/active_record_doctor/tasks/unindexed_deleted_at.rb +0 -19
- data/lib/active_record_doctor/tasks/unindexed_foreign_keys.rb +0 -43
- data/lib/active_record_doctor/tasks.rb +0 -7
- data/test/active_record_doctor/printers/io_printer_test.rb +0 -20
- data/test/active_record_doctor/tasks/extraneous_indexes_test.rb +0 -81
- data/test/active_record_doctor/tasks/incorrect_boolean_presence_validation_test.rb +0 -33
- data/test/active_record_doctor/tasks/missing_foreign_keys_test.rb +0 -27
- data/test/active_record_doctor/tasks/missing_non_null_constraint_test.rb +0 -108
- data/test/active_record_doctor/tasks/missing_presence_validation_test.rb +0 -110
- data/test/active_record_doctor/tasks/missing_unique_indexes_test.rb +0 -95
- data/test/active_record_doctor/tasks/undefined_table_references_test.rb +0 -51
- data/test/active_record_doctor/tasks/unindexed_deleted_at_test.rb +0 -34
- data/test/active_record_doctor/tasks/unindexed_foreign_keys_test.rb +0 -27
- data/test/dummy/README.rdoc +0 -28
- data/test/dummy/Rakefile +0 -6
- data/test/dummy/app/assets/config/manifest.js +0 -1
- data/test/dummy/app/assets/javascripts/application.js +0 -13
- data/test/dummy/app/assets/stylesheets/application.css +0 -15
- data/test/dummy/app/controllers/application_controller.rb +0 -5
- data/test/dummy/app/helpers/application_helper.rb +0 -2
- data/test/dummy/app/models/application_record.rb +0 -3
- data/test/dummy/app/views/layouts/application.html.erb +0 -14
- data/test/dummy/bin/bundle +0 -3
- data/test/dummy/bin/rails +0 -4
- data/test/dummy/bin/rake +0 -4
- data/test/dummy/bin/setup +0 -29
- data/test/dummy/config/application.rb +0 -23
- data/test/dummy/config/boot.rb +0 -5
- data/test/dummy/config/database.yml +0 -19
- data/test/dummy/config/database.yml.travis +0 -5
- data/test/dummy/config/environment.rb +0 -5
- data/test/dummy/config/environments/development.rb +0 -41
- data/test/dummy/config/environments/production.rb +0 -79
- data/test/dummy/config/environments/test.rb +0 -47
- data/test/dummy/config/initializers/assets.rb +0 -11
- data/test/dummy/config/initializers/backtrace_silencers.rb +0 -7
- data/test/dummy/config/initializers/cookies_serializer.rb +0 -3
- data/test/dummy/config/initializers/filter_parameter_logging.rb +0 -4
- data/test/dummy/config/initializers/inflections.rb +0 -16
- data/test/dummy/config/initializers/mime_types.rb +0 -4
- data/test/dummy/config/initializers/session_store.rb +0 -3
- data/test/dummy/config/initializers/wrap_parameters.rb +0 -14
- data/test/dummy/config/locales/en.yml +0 -23
- data/test/dummy/config/routes.rb +0 -56
- data/test/dummy/config/secrets.yml +0 -22
- data/test/dummy/config.ru +0 -4
- data/test/dummy/db/migrate/base_migration.rb +0 -5
- data/test/dummy/db/schema.rb +0 -19
- data/test/dummy/log/development.log +0 -111
- data/test/dummy/log/test.log +0 -79424
- data/test/dummy/public/404.html +0 -67
- data/test/dummy/public/422.html +0 -67
- data/test/dummy/public/500.html +0 -66
- data/test/dummy/public/favicon.ico +0 -0
- data/test/support/assertions.rb +0 -11
- data/test/support/temping.rb +0 -25
- data/test/test_helper.rb +0 -17
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecordDoctor
|
|
4
|
+
module Detectors
|
|
5
|
+
# Base class for all active_record_doctor detectors.
|
|
6
|
+
class Base
|
|
7
|
+
class << self
|
|
8
|
+
attr_reader :description, :config
|
|
9
|
+
|
|
10
|
+
def run(config, io)
|
|
11
|
+
new(config, io).run
|
|
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
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
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)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def connection
|
|
81
|
+
@connection ||= ActiveRecord::Base.connection
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def indexes(table_name, except: [])
|
|
85
|
+
connection.indexes(table_name).reject do |index|
|
|
86
|
+
except.include?(index.name)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
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
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def table_exists?(table_name)
|
|
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 }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def views
|
|
131
|
+
@views ||=
|
|
132
|
+
if connection.respond_to?(:views)
|
|
133
|
+
connection.views
|
|
134
|
+
elsif connection.adapter_name == "PostgreSQL"
|
|
135
|
+
ActiveRecord::Base.connection.execute(<<-SQL).map { |tuple| tuple.fetch("relname") }
|
|
136
|
+
SELECT c.relname FROM pg_class c WHERE c.relkind IN ('m', 'v')
|
|
137
|
+
SQL
|
|
138
|
+
else
|
|
139
|
+
# We don't support this Rails/database combination yet.
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def models(except: [])
|
|
145
|
+
ActiveRecord::Base.descendants.reject do |model|
|
|
146
|
+
model.name.start_with?("HABTM_") || except.include?(model.name)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def underscored_name
|
|
151
|
+
self.class.underscored_name
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record_doctor/detectors/base"
|
|
4
|
+
|
|
5
|
+
module ActiveRecordDoctor
|
|
6
|
+
module Detectors
|
|
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
|
+
}
|
|
19
|
+
|
|
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
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def detect
|
|
31
|
+
subindexes_of_multi_column_indexes
|
|
32
|
+
indexed_primary_keys
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def subindexes_of_multi_column_indexes
|
|
36
|
+
tables(except: config(:ignore_tables)).each do |table|
|
|
37
|
+
indexes = indexes(table)
|
|
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)
|
|
48
|
+
|
|
49
|
+
problem!(extraneous_index: index.name, replacement_indexes: replacement_indexes)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def indexed_primary_keys
|
|
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)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def maximal?(indexes, index)
|
|
66
|
+
indexes.all? do |another_index|
|
|
67
|
+
index == another_index || !cover?(another_index, index)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Does lhs cover rhs?
|
|
72
|
+
def cover?(lhs, rhs)
|
|
73
|
+
return false unless compatible_options?(lhs, rhs)
|
|
74
|
+
|
|
75
|
+
case [lhs.unique, rhs.unique]
|
|
76
|
+
when [true, true]
|
|
77
|
+
lhs.columns == rhs.columns
|
|
78
|
+
when [false, true]
|
|
79
|
+
false
|
|
80
|
+
else
|
|
81
|
+
prefix?(rhs, lhs)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def prefix?(lhs, rhs)
|
|
86
|
+
lhs.columns.count <= rhs.columns.count &&
|
|
87
|
+
rhs.columns[0...lhs.columns.count] == lhs.columns
|
|
88
|
+
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
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record_doctor/detectors/base"
|
|
4
|
+
|
|
5
|
+
module ActiveRecordDoctor
|
|
6
|
+
module Detectors
|
|
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
|
+
}
|
|
18
|
+
|
|
19
|
+
private
|
|
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
|
+
|
|
42
|
+
def has_presence_validator?(model, column)
|
|
43
|
+
model.validators.any? do |validator|
|
|
44
|
+
validator.kind == :presence && validator.attributes.include?(column.name.to_sym)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record_doctor/detectors/base"
|
|
4
|
+
|
|
5
|
+
module ActiveRecordDoctor
|
|
6
|
+
module Detectors
|
|
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
|
+
}
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
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
|
|
32
|
+
end
|
|
33
|
+
|
|
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)
|
|
41
|
+
|
|
42
|
+
associations.each do |association|
|
|
43
|
+
next if config(:ignore_associations).include?("#{model.name}.#{association.name}")
|
|
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
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def callback_action(reflection)
|
|
62
|
+
case reflection.options[:dependent]
|
|
63
|
+
when :delete, :delete_all then :skip
|
|
64
|
+
when :destroy then :invoke
|
|
65
|
+
end
|
|
66
|
+
end
|
|
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
|
+
|
|
80
|
+
def defines_destroy_callbacks?(model)
|
|
81
|
+
# Destroying an associated model involves loading it first hence
|
|
82
|
+
# initialize and find are present. If they are defined on the model
|
|
83
|
+
# being deleted then theoretically we can't use :delete_all. It's a bit
|
|
84
|
+
# of an edge case as they usually are either absent or have no side
|
|
85
|
+
# effects but we're being pedantic -- they could be used for audit
|
|
86
|
+
# trial, for instance, and we don't want to skip that.
|
|
87
|
+
model._initialize_callbacks.present? ||
|
|
88
|
+
model._find_callbacks.present? ||
|
|
89
|
+
model._destroy_callbacks.present? ||
|
|
90
|
+
model._commit_callbacks.present? ||
|
|
91
|
+
model._rollback_callbacks.present?
|
|
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
|
|
105
|
+
end
|
|
106
|
+
end
|
|
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
|
|
@@ -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 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
|
+
}
|
|
18
|
+
|
|
19
|
+
private
|
|
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
|
+
|
|
42
|
+
def named_like_foreign_key?(column)
|
|
43
|
+
column.name.end_with?("_id")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def foreign_key?(table, column)
|
|
47
|
+
connection.foreign_keys(table).any? do |foreign_key|
|
|
48
|
+
foreign_key.options[:column] == column.name
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def polymorphic_foreign_key?(table, column)
|
|
53
|
+
type_column_name = column.name.sub(/_id\Z/, "_type")
|
|
54
|
+
connection.columns(table).any? do |another_column|
|
|
55
|
+
another_column.name == type_column_name
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record_doctor/detectors/base"
|
|
4
|
+
|
|
5
|
+
module ActiveRecordDoctor
|
|
6
|
+
module Detectors
|
|
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
|
+
}
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
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| table.nil? || !table_exists?(table) }
|
|
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
|
+
|
|
41
|
+
problem!(column: column.name, table: table)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def sti_base_model?(model)
|
|
47
|
+
model.base_class == model &&
|
|
48
|
+
model.columns_hash.include?(model.inheritance_column.to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
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
|
+
belongs_to = model.reflect_on_all_associations(:belongs_to).find do |reflection|
|
|
58
|
+
reflection.foreign_key == column.name
|
|
59
|
+
end
|
|
60
|
+
attribute_name_forms << belongs_to.name.to_sym if belongs_to
|
|
61
|
+
|
|
62
|
+
model.validators.any? do |validator|
|
|
63
|
+
validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
|
|
64
|
+
(validator.attributes & attribute_name_forms).present? &&
|
|
65
|
+
!validator.options[:allow_nil] &&
|
|
66
|
+
!validator.options[:if] &&
|
|
67
|
+
!validator.options[:unless]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -1,30 +1,44 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record_doctor/detectors/base"
|
|
2
4
|
|
|
3
5
|
module ActiveRecordDoctor
|
|
4
|
-
module
|
|
5
|
-
class MissingPresenceValidation < Base
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
module Detectors
|
|
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
|
+
}
|
|
18
|
+
|
|
19
|
+
private
|
|
8
20
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
end.map do |model|
|
|
12
|
-
[
|
|
13
|
-
model.name,
|
|
14
|
-
connection.columns(model.table_name).select do |column|
|
|
15
|
-
validator_needed?(model, column) &&
|
|
16
|
-
!validator_present?(model, column)
|
|
17
|
-
end.map(&:name)
|
|
18
|
-
]
|
|
19
|
-
end.reject do |model_name, columns|
|
|
20
|
-
columns.empty?
|
|
21
|
-
end))
|
|
21
|
+
def message(column:, model:)
|
|
22
|
+
"add a `presence` validator to #{model}.#{column} - it's NOT NULL but lacks a validator"
|
|
22
23
|
end
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
def detect
|
|
26
|
+
models(except: config(:ignore_models)).each do |model|
|
|
27
|
+
next if model.table_name.nil?
|
|
28
|
+
next unless table_exists?(model.table_name)
|
|
29
|
+
|
|
30
|
+
connection.columns(model.table_name).each do |column|
|
|
31
|
+
next unless validator_needed?(model, column)
|
|
32
|
+
next if validator_present?(model, column)
|
|
33
|
+
next if config(:ignore_attributes).include?("#{model}.#{column.name}")
|
|
34
|
+
|
|
35
|
+
problem!(column: column.name, model: model.name)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
25
39
|
|
|
26
40
|
def validator_needed?(model, column)
|
|
27
|
-
![model.primary_key,
|
|
41
|
+
![model.primary_key, "created_at", "updated_at"].include?(column.name) &&
|
|
28
42
|
!column.null
|
|
29
43
|
end
|
|
30
44
|
|
|
@@ -49,7 +63,7 @@ module ActiveRecordDoctor
|
|
|
49
63
|
model.validators.any? do |validator|
|
|
50
64
|
validator.is_a?(ActiveModel::Validations::ExclusionValidator) &&
|
|
51
65
|
validator.attributes.include?(column.name.to_sym) &&
|
|
52
|
-
|
|
66
|
+
validator.options.fetch(:in, []).include?(nil)
|
|
53
67
|
end
|
|
54
68
|
end
|
|
55
69
|
|