active_record_doctor 1.7.2 → 1.8.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 +29 -0
- data/lib/active_record_doctor.rb +16 -12
- data/lib/active_record_doctor/detectors.rb +13 -0
- data/lib/active_record_doctor/detectors/base.rb +64 -0
- data/lib/active_record_doctor/{tasks → detectors}/extraneous_indexes.rb +11 -7
- data/lib/active_record_doctor/{tasks → detectors}/incorrect_boolean_presence_validation.rb +9 -6
- data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +71 -0
- data/lib/active_record_doctor/{tasks → detectors}/missing_foreign_keys.rb +13 -10
- data/lib/active_record_doctor/{tasks → detectors}/missing_non_null_constraint.rb +11 -7
- data/lib/active_record_doctor/{tasks → detectors}/missing_presence_validation.rb +11 -8
- data/lib/active_record_doctor/{tasks → detectors}/missing_unique_indexes.rb +8 -4
- data/lib/active_record_doctor/{tasks → detectors}/undefined_table_references.rb +11 -12
- data/lib/active_record_doctor/{tasks → detectors}/unindexed_deleted_at.rb +12 -6
- data/lib/active_record_doctor/{tasks → detectors}/unindexed_foreign_keys.rb +13 -10
- data/lib/active_record_doctor/printers.rb +3 -1
- data/lib/active_record_doctor/printers/io_printer.rb +63 -35
- data/lib/active_record_doctor/railtie.rb +2 -0
- data/lib/active_record_doctor/task.rb +28 -0
- data/lib/active_record_doctor/version.rb +3 -1
- data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +15 -11
- data/lib/tasks/active_record_doctor.rake +25 -25
- data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +67 -0
- data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +36 -0
- data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +117 -0
- data/test/active_record_doctor/detectors/missing_foreign_keys_test.rb +24 -0
- data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +102 -0
- data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +107 -0
- data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +114 -0
- data/test/active_record_doctor/detectors/undefined_table_references_test.rb +44 -0
- data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +67 -0
- data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +26 -0
- data/test/active_record_doctor/printers/io_printer_test.rb +14 -9
- data/test/model_factory.rb +78 -0
- data/test/setup.rb +69 -40
- metadata +70 -64
- data/lib/active_record_doctor/tasks.rb +0 -10
- data/lib/active_record_doctor/tasks/base.rb +0 -86
- data/test/active_record_doctor/tasks/extraneous_indexes_test.rb +0 -77
- data/test/active_record_doctor/tasks/incorrect_boolean_presence_validation_test.rb +0 -38
- data/test/active_record_doctor/tasks/missing_foreign_keys_test.rb +0 -23
- data/test/active_record_doctor/tasks/missing_non_null_constraint_test.rb +0 -113
- data/test/active_record_doctor/tasks/missing_presence_validation_test.rb +0 -115
- data/test/active_record_doctor/tasks/missing_unique_indexes_test.rb +0 -126
- data/test/active_record_doctor/tasks/undefined_table_references_test.rb +0 -47
- data/test/active_record_doctor/tasks/unindexed_deleted_at_test.rb +0 -59
- data/test/active_record_doctor/tasks/unindexed_foreign_keys_test.rb +0 -23
@@ -1,14 +1,18 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record_doctor/detectors/base"
|
2
4
|
|
3
5
|
module ActiveRecordDoctor
|
4
|
-
module
|
6
|
+
module Detectors
|
7
|
+
# Detect columns covered by a uniqueness validation that lack the corresponding unique index thus risking duplicate
|
8
|
+
# inserts.
|
5
9
|
class MissingUniqueIndexes < Base
|
6
|
-
@description =
|
10
|
+
@description = "Detect columns covered by a uniqueness validator without a unique index"
|
7
11
|
|
8
12
|
def run
|
9
13
|
eager_load!
|
10
14
|
|
11
|
-
|
15
|
+
problems(hash_from_pairs(models.reject do |model|
|
12
16
|
model.table_name.nil?
|
13
17
|
end.map do |model|
|
14
18
|
[
|
@@ -1,9 +1,12 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record_doctor/detectors/base"
|
2
4
|
|
3
5
|
module ActiveRecordDoctor
|
4
|
-
module
|
6
|
+
module Detectors
|
7
|
+
# Find models referencing non-existent database tables or views.
|
5
8
|
class UndefinedTableReferences < Base
|
6
|
-
@description =
|
9
|
+
@description = "Detect models referencing undefined tables or views"
|
7
10
|
|
8
11
|
def run
|
9
12
|
eager_load!
|
@@ -16,19 +19,15 @@ module ActiveRecordDoctor
|
|
16
19
|
offending_models = models.select do |model|
|
17
20
|
model.table_name.present? &&
|
18
21
|
!tables.include?(model.table_name) &&
|
19
|
-
|
20
|
-
|
22
|
+
(
|
23
|
+
existing_views.nil? ||
|
24
|
+
!existing_views.include?(model.table_name)
|
25
|
+
)
|
21
26
|
end.map do |model|
|
22
27
|
[model.name, model.table_name]
|
23
28
|
end
|
24
29
|
|
25
|
-
|
26
|
-
[
|
27
|
-
offending_models, # Actual results
|
28
|
-
!existing_views.nil? # true if views were checked, false otherwise
|
29
|
-
],
|
30
|
-
offending_models.blank?
|
31
|
-
]
|
30
|
+
problems(offending_models, views_checked: !existing_views.nil?)
|
32
31
|
end
|
33
32
|
end
|
34
33
|
end
|
@@ -1,14 +1,20 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record_doctor/detectors/base"
|
2
4
|
|
3
5
|
module ActiveRecordDoctor
|
4
|
-
module
|
6
|
+
module Detectors
|
7
|
+
# Find unindexed deleted_at columns.
|
5
8
|
class UnindexedDeletedAt < Base
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
+
PATTERN = [
|
10
|
+
"deleted_at",
|
11
|
+
"discarded_at"
|
12
|
+
].join("|").freeze
|
13
|
+
|
14
|
+
@description = "Detect unindexed deleted_at columns"
|
9
15
|
|
10
16
|
def run
|
11
|
-
|
17
|
+
problems(connection.tables.select do |table|
|
12
18
|
connection.columns(table).any? { |column| column.name =~ /^#{PATTERN}$/ }
|
13
19
|
end.flat_map do |table|
|
14
20
|
connection.indexes(table).reject do |index|
|
@@ -1,30 +1,33 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record_doctor/detectors/base"
|
2
4
|
|
3
5
|
module ActiveRecordDoctor
|
4
|
-
module
|
6
|
+
module Detectors
|
7
|
+
# Find foreign keys that lack indexes (usually recommended for performance reasons).
|
5
8
|
class UnindexedForeignKeys < Base
|
6
|
-
@description =
|
9
|
+
@description = "Detect foreign keys without an index on them"
|
7
10
|
|
8
11
|
def run
|
9
|
-
|
10
|
-
"schema_migrations"
|
12
|
+
problems(hash_from_pairs(tables.reject do |table|
|
13
|
+
table == "schema_migrations"
|
11
14
|
end.map do |table|
|
12
15
|
[
|
13
16
|
table,
|
14
17
|
connection.columns(table).select do |column|
|
15
|
-
foreign_key?(
|
18
|
+
foreign_key?(column) &&
|
16
19
|
!indexed?(table, column) &&
|
17
20
|
!indexed_as_polymorphic?(table, column)
|
18
21
|
end.map(&:name)
|
19
22
|
]
|
20
|
-
end.
|
21
|
-
|
23
|
+
end.reject do |_table, columns|
|
24
|
+
columns.empty?
|
22
25
|
end))
|
23
26
|
end
|
24
27
|
|
25
28
|
private
|
26
29
|
|
27
|
-
def foreign_key?(
|
30
|
+
def foreign_key?(column)
|
28
31
|
column.name.end_with?("_id")
|
29
32
|
end
|
30
33
|
|
@@ -35,7 +38,7 @@ module ActiveRecordDoctor
|
|
35
38
|
end
|
36
39
|
|
37
40
|
def indexed_as_polymorphic?(table, column)
|
38
|
-
type_column_name = column.name.sub(/_id\Z/,
|
41
|
+
type_column_name = column.name.sub(/_id\Z/, "_type")
|
39
42
|
connection.indexes(table).any? do |index|
|
40
43
|
index.columns == [type_column_name, column.name]
|
41
44
|
end
|
@@ -1,72 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module ActiveRecordDoctor
|
2
4
|
module Printers
|
5
|
+
# Default printer for displaying messages produced by active_record_doctor.
|
3
6
|
class IOPrinter
|
4
|
-
def initialize(io =
|
7
|
+
def initialize(io = $stdout)
|
5
8
|
@io = io
|
6
9
|
end
|
7
10
|
|
8
|
-
def unindexed_foreign_keys(unindexed_foreign_keys)
|
11
|
+
def unindexed_foreign_keys(unindexed_foreign_keys, _options)
|
12
|
+
return if unindexed_foreign_keys.empty?
|
13
|
+
|
14
|
+
@io.puts("The following foreign keys should be indexed for performance reasons:")
|
9
15
|
@io.puts(unindexed_foreign_keys.sort.map do |table, columns|
|
10
|
-
"#{table} #{columns.sort.join(' ')}"
|
16
|
+
" #{table} #{columns.sort.join(' ')}"
|
11
17
|
end.join("\n"))
|
12
18
|
end
|
13
19
|
|
14
|
-
def extraneous_indexes(extraneous_indexes)
|
15
|
-
if extraneous_indexes.empty?
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
fail("unknown reason #{reason.inspect}")
|
28
|
-
end
|
20
|
+
def extraneous_indexes(extraneous_indexes, _options)
|
21
|
+
return if extraneous_indexes.empty?
|
22
|
+
|
23
|
+
@io.puts("The following indexes are extraneous and can be removed:")
|
24
|
+
extraneous_indexes.each do |index, details|
|
25
|
+
reason, *params = details
|
26
|
+
case reason
|
27
|
+
when :multi_column
|
28
|
+
@io.puts(" #{index} (can be handled by #{params.join(', ')})")
|
29
|
+
when :primary_key
|
30
|
+
@io.puts(" #{index} (is a primary key of #{params[0]})")
|
31
|
+
else
|
32
|
+
raise("unknown reason #{reason.inspect}")
|
29
33
|
end
|
30
34
|
end
|
31
35
|
end
|
32
36
|
|
33
|
-
def missing_foreign_keys(missing_foreign_keys)
|
37
|
+
def missing_foreign_keys(missing_foreign_keys, _options)
|
38
|
+
return if missing_foreign_keys.empty?
|
39
|
+
|
40
|
+
@io.puts("The following columns lack a foreign key constraint:")
|
34
41
|
@io.puts(missing_foreign_keys.sort.map do |table, columns|
|
35
|
-
"#{table} #{columns.sort.join(' ')}"
|
42
|
+
" #{table} #{columns.sort.join(' ')}"
|
36
43
|
end.join("\n"))
|
37
44
|
end
|
38
45
|
|
39
|
-
def undefined_table_references(
|
46
|
+
def undefined_table_references(models, options)
|
40
47
|
return if models.empty?
|
41
48
|
|
42
|
-
unless views_checked
|
43
|
-
@io.puts(<<
|
49
|
+
unless options.fetch(:views_checked)
|
50
|
+
@io.puts(<<WARNING)
|
44
51
|
WARNING: Models backed by database views are supported only in Rails 5+ OR
|
45
52
|
Rails 4.2 + PostgreSQL. It seems this is NOT your setup. Therefore, such models
|
46
53
|
will be erroneously reported below as not having their underlying tables/views.
|
47
54
|
Consider upgrading Rails or disabling this task temporarily.
|
48
|
-
|
55
|
+
WARNING
|
49
56
|
end
|
50
57
|
|
51
|
-
@io.puts(
|
58
|
+
@io.puts("The following models reference undefined tables:")
|
52
59
|
models.each do |model_name, table_name|
|
53
60
|
@io.puts(" #{model_name} (the table #{table_name} is undefined)")
|
54
61
|
end
|
55
62
|
end
|
56
63
|
|
57
|
-
def unindexed_deleted_at(indexes)
|
64
|
+
def unindexed_deleted_at(indexes, _options)
|
58
65
|
return if indexes.empty?
|
59
66
|
|
60
|
-
@io.puts(
|
67
|
+
@io.puts("The following indexes should include `deleted_at IS NULL`:")
|
61
68
|
indexes.each do |index|
|
62
69
|
@io.puts(" #{index}")
|
63
70
|
end
|
64
71
|
end
|
65
72
|
|
66
|
-
def missing_unique_indexes(indexes)
|
73
|
+
def missing_unique_indexes(indexes, _options)
|
67
74
|
return if indexes.empty?
|
68
75
|
|
69
|
-
@io.puts(
|
76
|
+
@io.puts("The following indexes should be created to back model-level uniqueness validations:")
|
70
77
|
indexes.each do |table, arrays_of_columns|
|
71
78
|
arrays_of_columns.each do |columns|
|
72
79
|
@io.puts(" #{table}: #{columns.join(', ')}")
|
@@ -74,32 +81,53 @@ EOS
|
|
74
81
|
end
|
75
82
|
end
|
76
83
|
|
77
|
-
def missing_presence_validation(missing_presence_validators)
|
84
|
+
def missing_presence_validation(missing_presence_validators, _options)
|
78
85
|
return if missing_presence_validators.empty?
|
79
86
|
|
80
|
-
@io.puts(
|
87
|
+
@io.puts("The following models and columns should have presence validations:")
|
81
88
|
missing_presence_validators.each do |model_name, array_of_columns|
|
82
89
|
@io.puts(" #{model_name}: #{array_of_columns.join(', ')}")
|
83
90
|
end
|
84
91
|
end
|
85
92
|
|
86
|
-
def missing_non_null_constraint(missing_non_null_constraints)
|
93
|
+
def missing_non_null_constraint(missing_non_null_constraints, _options)
|
87
94
|
return if missing_non_null_constraints.empty?
|
88
95
|
|
89
|
-
@io.puts(
|
96
|
+
@io.puts("The following columns should be marked as `null: false`:")
|
90
97
|
missing_non_null_constraints.each do |table, columns|
|
91
98
|
@io.puts(" #{table}: #{columns.join(', ')}")
|
92
99
|
end
|
93
100
|
end
|
94
101
|
|
95
|
-
def incorrect_boolean_presence_validation(incorrect_boolean_presence_validations)
|
102
|
+
def incorrect_boolean_presence_validation(incorrect_boolean_presence_validations, _options)
|
96
103
|
return if incorrect_boolean_presence_validations.empty?
|
97
104
|
|
98
|
-
@io.puts(
|
105
|
+
@io.puts("The presence of the following boolean columns is validated incorrectly:")
|
99
106
|
incorrect_boolean_presence_validations.each do |table, columns|
|
100
107
|
@io.puts(" #{table}: #{columns.join(', ')}")
|
101
108
|
end
|
102
109
|
end
|
110
|
+
|
111
|
+
def incorrect_dependent_option(problems, _options)
|
112
|
+
return if problems.empty?
|
113
|
+
|
114
|
+
@io.puts("The following associations might be using invalid dependent settings:")
|
115
|
+
problems.each do |model, associations|
|
116
|
+
associations.each do |(name, problem)|
|
117
|
+
# rubocop:disable Layout/LineLength
|
118
|
+
message =
|
119
|
+
case problem
|
120
|
+
when :suggest_destroy then "skips callbacks that are defined on the associated model - consider changing to `dependent: :destroy` or similar"
|
121
|
+
when :suggest_delete then "loads the associated model before deleting it - consider using `dependent: :delete`"
|
122
|
+
when :suggest_delete_all then "loads models one-by-one to invoke callbacks even though the related model defines none - consider using `dependent: :delete_all`"
|
123
|
+
else next
|
124
|
+
end
|
125
|
+
# rubocop:enable Layout/LineLength
|
126
|
+
|
127
|
+
@io.puts(" #{model}: #{name} #{message}")
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
103
131
|
end
|
104
132
|
end
|
105
133
|
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecordDoctor
|
4
|
+
# Rake task for running a detector and reporting its results.
|
5
|
+
class Task
|
6
|
+
DEFAULT_PRINTER = ActiveRecordDoctor::Printers::IOPrinter.new
|
7
|
+
|
8
|
+
def initialize(detector_class, printer = DEFAULT_PRINTER)
|
9
|
+
@detector_class = detector_class
|
10
|
+
@printer = printer
|
11
|
+
end
|
12
|
+
|
13
|
+
def name
|
14
|
+
@detector_class.name.demodulize.underscore.to_sym
|
15
|
+
end
|
16
|
+
|
17
|
+
def description
|
18
|
+
@detector_class.description
|
19
|
+
end
|
20
|
+
|
21
|
+
def run
|
22
|
+
problems, options = @detector_class.run
|
23
|
+
@printer.public_send(name, problems, options)
|
24
|
+
|
25
|
+
problems.empty?
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -1,9 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module ActiveRecordDoctor
|
4
|
+
# Generate migrations that add missing indexes to the database.
|
2
5
|
class AddIndexesGenerator < Rails::Generators::Base
|
3
6
|
MigrationDescription = Struct.new(:table, :columns)
|
4
7
|
|
5
|
-
desc
|
6
|
-
argument :path, type: :string, default: nil, banner:
|
8
|
+
desc "Generate migrations for the specified indexes"
|
9
|
+
argument :path, type: :string, default: nil, banner: "PATH"
|
7
10
|
|
8
11
|
def create_migrations
|
9
12
|
migration_descriptions = read_migration_descriptions(path)
|
@@ -23,10 +26,10 @@ module ActiveRecordDoctor
|
|
23
26
|
table, *columns = line.split(/\s+/)
|
24
27
|
|
25
28
|
if table.empty?
|
26
|
-
|
29
|
+
raise("No table name in #{path} on line #{index + 1}. Ensure the line doesn't start with whitespace.")
|
27
30
|
end
|
28
31
|
if columns.empty?
|
29
|
-
|
32
|
+
raise("No columns for table #{table} in #{path} on line #{index + 1}.")
|
30
33
|
end
|
31
34
|
|
32
35
|
MigrationDescription.new(table, columns)
|
@@ -34,13 +37,16 @@ module ActiveRecordDoctor
|
|
34
37
|
end
|
35
38
|
|
36
39
|
def content(migration_description)
|
37
|
-
|
40
|
+
# In order to properly indent the resulting code, we must disable the
|
41
|
+
# rubocop rule below.
|
42
|
+
|
43
|
+
<<MIGRATION
|
38
44
|
class IndexForeignKeysIn#{migration_description.table.camelize} < ActiveRecord::Migration#{migration_version}
|
39
45
|
def change
|
40
46
|
#{add_indexes(migration_description)}
|
41
47
|
end
|
42
48
|
end
|
43
|
-
|
49
|
+
MIGRATION
|
44
50
|
end
|
45
51
|
|
46
52
|
def add_indexes(migration_description)
|
@@ -54,12 +60,10 @@ EOF
|
|
54
60
|
end
|
55
61
|
|
56
62
|
def migration_version
|
57
|
-
|
58
|
-
|
59
|
-
if major >= 5 && minor >= 1
|
60
|
-
"[#{major}.#{minor}]"
|
63
|
+
if ActiveRecord::VERSION::STRING >= "5.1"
|
64
|
+
"[#{version}]"
|
61
65
|
else
|
62
|
-
|
66
|
+
""
|
63
67
|
end
|
64
68
|
end
|
65
69
|
end
|
@@ -1,33 +1,33 @@
|
|
1
|
-
|
2
|
-
require "active_record_doctor/tasks/unindexed_foreign_keys"
|
3
|
-
require "active_record_doctor/tasks/extraneous_indexes"
|
4
|
-
require "active_record_doctor/tasks/missing_foreign_keys"
|
5
|
-
require "active_record_doctor/tasks/undefined_table_references"
|
6
|
-
require "active_record_doctor/tasks/unindexed_deleted_at"
|
7
|
-
require "active_record_doctor/tasks/missing_unique_indexes"
|
8
|
-
require "active_record_doctor/tasks/missing_presence_validation"
|
9
|
-
require "active_record_doctor/tasks/missing_non_null_constraint"
|
10
|
-
require "active_record_doctor/tasks/incorrect_boolean_presence_validation"
|
1
|
+
# frozen_string_literal: true
|
11
2
|
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
3
|
+
require "active_record_doctor/detectors"
|
4
|
+
require "active_record_doctor/detectors/unindexed_foreign_keys"
|
5
|
+
require "active_record_doctor/detectors/extraneous_indexes"
|
6
|
+
require "active_record_doctor/detectors/missing_foreign_keys"
|
7
|
+
require "active_record_doctor/detectors/undefined_table_references"
|
8
|
+
require "active_record_doctor/detectors/unindexed_deleted_at"
|
9
|
+
require "active_record_doctor/detectors/missing_unique_indexes"
|
10
|
+
require "active_record_doctor/detectors/missing_presence_validation"
|
11
|
+
require "active_record_doctor/detectors/missing_non_null_constraint"
|
12
|
+
require "active_record_doctor/detectors/incorrect_boolean_presence_validation"
|
13
|
+
require "active_record_doctor/detectors/incorrect_dependent_option"
|
14
|
+
require "active_record_doctor/task"
|
20
15
|
|
21
|
-
|
22
|
-
|
16
|
+
namespace :active_record_doctor do
|
17
|
+
tasks = ActiveRecordDoctor::Detectors.all.map do |detector_class|
|
18
|
+
ActiveRecordDoctor::Task.new(detector_class)
|
19
|
+
end
|
23
20
|
|
24
|
-
|
25
|
-
|
26
|
-
|
21
|
+
tasks.each do |task|
|
22
|
+
desc task.description
|
23
|
+
task task.name => :environment do
|
24
|
+
task.run or exit(1)
|
27
25
|
end
|
28
26
|
end
|
29
27
|
|
30
|
-
|
31
|
-
|
28
|
+
desc "Run all active_record_doctor tasks"
|
29
|
+
task :all => :environment do
|
30
|
+
results = tasks.map { |task| task.run }
|
31
|
+
results.all? or exit(1)
|
32
32
|
end
|
33
33
|
end
|