active_record_doctor 1.5.0 → 1.6.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 +73 -6
- data/lib/active_record_doctor/printers/io_printer.rb +35 -6
- data/lib/active_record_doctor/railtie.rb +1 -1
- data/lib/active_record_doctor/tasks.rb +3 -0
- data/lib/active_record_doctor/tasks/base.rb +64 -0
- data/lib/active_record_doctor/tasks/extraneous_indexes.rb +4 -27
- data/lib/active_record_doctor/tasks/missing_foreign_keys.rb +6 -29
- data/lib/active_record_doctor/tasks/missing_non_null_constraint.rb +42 -0
- data/lib/active_record_doctor/tasks/missing_presence_validation.rb +39 -0
- data/lib/active_record_doctor/tasks/missing_unique_indexes.rb +51 -0
- data/lib/active_record_doctor/tasks/undefined_table_references.rb +6 -22
- data/lib/active_record_doctor/tasks/unindexed_deleted_at.rb +4 -25
- data/lib/active_record_doctor/tasks/unindexed_foreign_keys.rb +6 -29
- data/lib/active_record_doctor/version.rb +1 -1
- data/lib/tasks/active_record_doctor.rake +31 -0
- data/test/active_record_doctor/printers/io_printer_test.rb +4 -4
- data/test/active_record_doctor/tasks/extraneous_indexes_test.rb +70 -16
- data/test/active_record_doctor/tasks/missing_foreign_keys_test.rb +16 -8
- data/test/active_record_doctor/tasks/missing_non_null_constraint_test.rb +89 -0
- data/test/active_record_doctor/tasks/missing_presence_validation_test.rb +49 -0
- data/test/active_record_doctor/tasks/missing_unique_indexes_test.rb +95 -0
- data/test/active_record_doctor/tasks/unindexed_deleted_at_test.rb +23 -8
- data/test/active_record_doctor/tasks/unindexed_foreign_keys_test.rb +16 -8
- data/test/dummy/app/models/application_record.rb +1 -1
- data/test/dummy/db/schema.rb +1 -50
- data/test/dummy/log/development.log +38 -498
- data/test/dummy/log/test.log +54108 -1571
- data/test/support/assertions.rb +11 -0
- data/test/support/forking_test.rb +28 -0
- data/test/support/temping.rb +25 -0
- data/test/test_helper.rb +0 -7
- metadata +34 -27
- data/lib/active_record_doctor/compatibility.rb +0 -11
- data/lib/tasks/active_record_doctor_tasks.rake +0 -27
- data/test/active_record_doctor/tasks/undefined_table_references_test.rb +0 -19
- data/test/dummy/app/models/comment.rb +0 -3
- data/test/dummy/app/models/contract.rb +0 -3
- data/test/dummy/app/models/employer.rb +0 -2
- data/test/dummy/app/models/profile.rb +0 -2
- data/test/dummy/app/models/user.rb +0 -3
- data/test/dummy/db/migrate/20160213101213_create_employers.rb +0 -15
- data/test/dummy/db/migrate/20160213101221_create_users.rb +0 -23
- data/test/dummy/db/migrate/20160213101232_create_profiles.rb +0 -15
- data/test/dummy/db/migrate/20160604081452_create_comments.rb +0 -11
- data/test/support/spy_printer.rb +0 -52
@@ -0,0 +1,39 @@
|
|
1
|
+
require "active_record_doctor/tasks/base"
|
2
|
+
|
3
|
+
module ActiveRecordDoctor
|
4
|
+
module Tasks
|
5
|
+
class MissingPresenceValidation < Base
|
6
|
+
def run
|
7
|
+
eager_load!
|
8
|
+
|
9
|
+
success(hash_from_pairs(models.reject do |model|
|
10
|
+
model.table_name.nil? || model.table_name == 'schema_migrations'
|
11
|
+
end.map do |model|
|
12
|
+
[
|
13
|
+
model.name,
|
14
|
+
connection.columns(model.table_name).select do |column|
|
15
|
+
validator_needed?(model, column) &&
|
16
|
+
!column.null &&
|
17
|
+
!has_presence_validator?(model, column)
|
18
|
+
end.map(&:name)
|
19
|
+
]
|
20
|
+
end.reject do |model_name, columns|
|
21
|
+
columns.empty?
|
22
|
+
end))
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def validator_needed?(model, column)
|
28
|
+
![model.primary_key, 'created_at', 'updated_at'].include?(column.name)
|
29
|
+
end
|
30
|
+
|
31
|
+
def has_presence_validator?(model, column)
|
32
|
+
model.validators.any? do |validator|
|
33
|
+
validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
|
34
|
+
validator.attributes.include?(column.name.to_sym)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require "active_record_doctor/tasks/base"
|
2
|
+
|
3
|
+
module ActiveRecordDoctor
|
4
|
+
module Tasks
|
5
|
+
class MissingUniqueIndexes < Base
|
6
|
+
def run
|
7
|
+
eager_load!
|
8
|
+
|
9
|
+
success(hash_from_pairs(models.reject do |model|
|
10
|
+
model.table_name.nil?
|
11
|
+
end.map do |model|
|
12
|
+
[
|
13
|
+
model.table_name,
|
14
|
+
model.validators.select do |validator|
|
15
|
+
table_name = model.table_name
|
16
|
+
attributes = validator.attributes
|
17
|
+
scope = validator.options.fetch(:scope, [])
|
18
|
+
|
19
|
+
validator.is_a?(ActiveRecord::Validations::UniquenessValidator) &&
|
20
|
+
supported_validator?(validator) &&
|
21
|
+
!unique_index?(table_name, attributes, scope)
|
22
|
+
end.map do |validator|
|
23
|
+
scope = Array(validator.options.fetch(:scope, []))
|
24
|
+
attributes = validator.attributes
|
25
|
+
(scope + attributes).map(&:to_s)
|
26
|
+
end
|
27
|
+
]
|
28
|
+
end.reject do |_table_name, indexes|
|
29
|
+
indexes.empty?
|
30
|
+
end))
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def supported_validator?(validator)
|
36
|
+
validator.options[:if].nil? &&
|
37
|
+
validator.options[:unless].nil? &&
|
38
|
+
validator.options[:conditions].nil? &&
|
39
|
+
validator.options[:case_sensitive]
|
40
|
+
end
|
41
|
+
|
42
|
+
def unique_index?(table_name, columns, scope)
|
43
|
+
columns = (Array(scope) + columns).map(&:to_s)
|
44
|
+
|
45
|
+
indexes(table_name).any? do |index|
|
46
|
+
index.columns == columns && index.unique
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -1,33 +1,17 @@
|
|
1
|
-
require "active_record_doctor/
|
2
|
-
require "active_record_doctor/printers/io_printer"
|
1
|
+
require "active_record_doctor/tasks/base"
|
3
2
|
|
4
3
|
module ActiveRecordDoctor
|
5
4
|
module Tasks
|
6
|
-
class UndefinedTableReferences
|
7
|
-
include Compatibility
|
8
|
-
|
9
|
-
def self.run
|
10
|
-
new.run
|
11
|
-
end
|
12
|
-
|
13
|
-
def initialize(printer: ActiveRecordDoctor::Printers::IOPrinter.new)
|
14
|
-
@printer = printer
|
15
|
-
end
|
16
|
-
|
5
|
+
class UndefinedTableReferences < Base
|
17
6
|
def run
|
18
|
-
|
19
|
-
undefined_table_references.present? ? 1 : 0
|
20
|
-
end
|
21
|
-
|
22
|
-
private
|
7
|
+
eager_load!
|
23
8
|
|
24
|
-
|
25
|
-
Rails.application.eager_load!
|
26
|
-
|
27
|
-
ActiveRecord::Base.descendants.select do |model|
|
9
|
+
offending_models = models.select do |model|
|
28
10
|
model.table_name.present? &&
|
29
11
|
!model.connection.tables.include?(model.table_name)
|
30
12
|
end
|
13
|
+
|
14
|
+
[offending_models, offending_models.blank?]
|
31
15
|
end
|
32
16
|
end
|
33
17
|
end
|
@@ -1,27 +1,10 @@
|
|
1
|
-
require "active_record_doctor/
|
2
|
-
require "active_record_doctor/printers/io_printer"
|
1
|
+
require "active_record_doctor/tasks/base"
|
3
2
|
|
4
3
|
module ActiveRecordDoctor
|
5
4
|
module Tasks
|
6
|
-
class UnindexedDeletedAt
|
7
|
-
include Compatibility
|
8
|
-
|
9
|
-
def self.run
|
10
|
-
new.run
|
11
|
-
end
|
12
|
-
|
13
|
-
def initialize(printer: ActiveRecordDoctor::Printers::IOPrinter.new)
|
14
|
-
@printer = printer
|
15
|
-
end
|
16
|
-
|
5
|
+
class UnindexedDeletedAt < Base
|
17
6
|
def run
|
18
|
-
|
19
|
-
end
|
20
|
-
|
21
|
-
private
|
22
|
-
|
23
|
-
def unindexed_deleted_at
|
24
|
-
connection.tables.select do |table|
|
7
|
+
success(connection.tables.select do |table|
|
25
8
|
connection.columns(table).map(&:name).include?('deleted_at')
|
26
9
|
end.flat_map do |table|
|
27
10
|
connection.indexes(table).reject do |index|
|
@@ -29,11 +12,7 @@ module ActiveRecordDoctor
|
|
29
12
|
end.map do |index|
|
30
13
|
index.name
|
31
14
|
end
|
32
|
-
end
|
33
|
-
end
|
34
|
-
|
35
|
-
def connection
|
36
|
-
@connection ||= ActiveRecord::Base.connection
|
15
|
+
end)
|
37
16
|
end
|
38
17
|
end
|
39
18
|
end
|
@@ -1,27 +1,10 @@
|
|
1
|
-
require "active_record_doctor/
|
2
|
-
require "active_record_doctor/printers/io_printer"
|
1
|
+
require "active_record_doctor/tasks/base"
|
3
2
|
|
4
3
|
module ActiveRecordDoctor
|
5
4
|
module Tasks
|
6
|
-
class UnindexedForeignKeys
|
7
|
-
include Compatibility
|
8
|
-
|
9
|
-
def self.run
|
10
|
-
new.run
|
11
|
-
end
|
12
|
-
|
13
|
-
def initialize(printer: ActiveRecordDoctor::Printers::IOPrinter.new)
|
14
|
-
@printer = printer
|
15
|
-
end
|
16
|
-
|
5
|
+
class UnindexedForeignKeys < Base
|
17
6
|
def run
|
18
|
-
|
19
|
-
end
|
20
|
-
|
21
|
-
private
|
22
|
-
|
23
|
-
def unindexed_foreign_keys
|
24
|
-
hash_from_pairs(connection_tables.select do |table|
|
7
|
+
success(hash_from_pairs(tables.select do |table|
|
25
8
|
"schema_migrations" != table
|
26
9
|
end.map do |table|
|
27
10
|
[
|
@@ -34,9 +17,11 @@ module ActiveRecordDoctor
|
|
34
17
|
]
|
35
18
|
end.select do |table, columns|
|
36
19
|
!columns.empty?
|
37
|
-
end)
|
20
|
+
end))
|
38
21
|
end
|
39
22
|
|
23
|
+
private
|
24
|
+
|
40
25
|
def foreign_key?(table, column)
|
41
26
|
column.name.end_with?("_id")
|
42
27
|
end
|
@@ -53,14 +38,6 @@ module ActiveRecordDoctor
|
|
53
38
|
index.columns == [type_column_name, column.name]
|
54
39
|
end
|
55
40
|
end
|
56
|
-
|
57
|
-
def connection
|
58
|
-
@connection ||= ActiveRecord::Base.connection
|
59
|
-
end
|
60
|
-
|
61
|
-
def hash_from_pairs(pairs)
|
62
|
-
Hash[*pairs.flatten(1)]
|
63
|
-
end
|
64
41
|
end
|
65
42
|
end
|
66
43
|
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require "active_record_doctor/tasks"
|
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
|
+
|
11
|
+
namespace :active_record_doctor do
|
12
|
+
def mount(task_class)
|
13
|
+
name = task_class.name.demodulize.underscore.to_sym
|
14
|
+
|
15
|
+
task name => :environment do
|
16
|
+
result, success = task_class.run
|
17
|
+
success = true if success.nil?
|
18
|
+
|
19
|
+
printer = ActiveRecordDoctor::Printers::IOPrinter.new
|
20
|
+
printer.public_send(name, result)
|
21
|
+
|
22
|
+
# nil doesn't indicate a failure but rather no explicit result. We assume
|
23
|
+
# success by default hence only false results in an erroneous exit code.
|
24
|
+
exit(1) if success == false
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
ActiveRecordDoctor::Tasks.all.each do |task|
|
29
|
+
mount task
|
30
|
+
end
|
31
|
+
end
|
@@ -3,8 +3,8 @@ require 'test_helper'
|
|
3
3
|
require 'active_record_doctor/printers/io_printer'
|
4
4
|
|
5
5
|
class ActiveRecordDoctor::Printers::IOPrinterTest < ActiveSupport::TestCase
|
6
|
-
def
|
7
|
-
assert_equal(<<EOF,
|
6
|
+
def test_unindexed_foreign_keys
|
7
|
+
assert_equal(<<EOF, unindexed_foreign_keys({ "users" => ["profile_id", "account_id"], "account" => ["group_id"] }))
|
8
8
|
account group_id
|
9
9
|
users account_id profile_id
|
10
10
|
EOF
|
@@ -12,9 +12,9 @@ EOF
|
|
12
12
|
|
13
13
|
private
|
14
14
|
|
15
|
-
def
|
15
|
+
def unindexed_foreign_keys(argument)
|
16
16
|
io = StringIO.new
|
17
|
-
ActiveRecordDoctor::Printers::IOPrinter.new(io
|
17
|
+
ActiveRecordDoctor::Printers::IOPrinter.new(io).unindexed_foreign_keys(argument)
|
18
18
|
io.string
|
19
19
|
end
|
20
20
|
end
|
@@ -1,27 +1,81 @@
|
|
1
1
|
require 'test_helper'
|
2
2
|
|
3
3
|
require 'active_record_doctor/tasks/extraneous_indexes'
|
4
|
-
|
4
|
+
|
5
5
|
class ActiveRecordDoctor::Tasks::ExtraneousIndexesTest < ActiveSupport::TestCase
|
6
|
-
def
|
7
|
-
|
6
|
+
def test_index_on_primary_key_is_duplicate
|
7
|
+
Temping.create(:user, temporary: false) do
|
8
|
+
with_columns do |t|
|
9
|
+
t.index :id
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
assert_result([['index_users_on_id', [:primary_key, 'users']]])
|
14
|
+
end
|
15
|
+
|
16
|
+
def test_non_unique_version_of_index_is_duplicate
|
17
|
+
Temping.create(:user, temporary: false) do
|
18
|
+
with_columns do |t|
|
19
|
+
t.string :email
|
20
|
+
t.index :email, name: 'index_users_on_email'
|
21
|
+
t.index :email, unique: true, name: 'unique_index_on_users_email'
|
22
|
+
end
|
23
|
+
end
|
8
24
|
|
9
|
-
|
25
|
+
assert_result([
|
26
|
+
['index_users_on_email', [:multi_column, 'unique_index_on_users_email']]
|
27
|
+
])
|
28
|
+
end
|
29
|
+
|
30
|
+
def test_single_column_covered_by_unique_and_non_unique_multi_column_is_duplicate
|
31
|
+
Temping.create(:user, temporary: false) do
|
32
|
+
with_columns do |t|
|
33
|
+
t.string :first_name
|
34
|
+
t.string :last_name
|
35
|
+
t.string :email
|
36
|
+
t.index [:last_name, :first_name, :email]
|
37
|
+
t.index [:last_name, :first_name],
|
38
|
+
unique: true,
|
39
|
+
name: 'unique_index_on_users_last_name_and_first_name'
|
40
|
+
t.index :last_name
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
assert_result([
|
10
45
|
[
|
11
|
-
|
12
|
-
[
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
46
|
+
'index_users_on_last_name',
|
47
|
+
[
|
48
|
+
:multi_column,
|
49
|
+
'index_users_on_last_name_and_first_name_and_email',
|
50
|
+
'unique_index_on_users_last_name_and_first_name'
|
51
|
+
]
|
52
|
+
]
|
53
|
+
])
|
18
54
|
end
|
19
55
|
|
20
|
-
|
56
|
+
def test_multi_column_covered_by_unique_and_non_unique_multi_column_is_duplicate
|
57
|
+
Temping.create(:user, temporary: false) do
|
58
|
+
with_columns do |t|
|
59
|
+
t.string :first_name
|
60
|
+
t.string :last_name
|
61
|
+
t.string :email
|
62
|
+
t.index [:last_name, :first_name, :email]
|
63
|
+
t.index [:last_name, :first_name],
|
64
|
+
unique: true,
|
65
|
+
name: 'unique_index_on_users_last_name_and_first_name'
|
66
|
+
t.index [:last_name, :first_name]
|
67
|
+
end
|
68
|
+
end
|
21
69
|
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
70
|
+
assert_result([
|
71
|
+
[
|
72
|
+
'index_users_on_last_name_and_first_name',
|
73
|
+
[
|
74
|
+
:multi_column,
|
75
|
+
'index_users_on_last_name_and_first_name_and_email',
|
76
|
+
'unique_index_on_users_last_name_and_first_name'
|
77
|
+
]
|
78
|
+
]
|
79
|
+
])
|
26
80
|
end
|
27
81
|
end
|
@@ -3,17 +3,25 @@ require 'test_helper'
|
|
3
3
|
require 'active_record_doctor/tasks/missing_foreign_keys'
|
4
4
|
|
5
5
|
class ActiveRecordDoctor::Tasks::MissingForeignKeysTest < ActiveSupport::TestCase
|
6
|
-
def
|
7
|
-
|
6
|
+
def test_missing_foreign_key_is_reported
|
7
|
+
Temping.create(:companies, temporary: false)
|
8
|
+
Temping.create(:users, temporary: false) do
|
9
|
+
with_columns do |t|
|
10
|
+
t.references :company, foreign_key: false
|
11
|
+
end
|
12
|
+
end
|
8
13
|
|
9
|
-
assert_equal({'users' => ['
|
14
|
+
assert_equal({'users' => ['company_id']}, run_task)
|
10
15
|
end
|
11
16
|
|
12
|
-
|
17
|
+
def test_present_foreign_key_is_not_reported
|
18
|
+
Temping.create(:companies, temporary: false)
|
19
|
+
Temping.create(:users, temporary: false) do
|
20
|
+
with_columns do |t|
|
21
|
+
t.references :company, foreign_key: true
|
22
|
+
end
|
23
|
+
end
|
13
24
|
|
14
|
-
|
15
|
-
printer = SpyPrinter.new
|
16
|
-
ActiveRecordDoctor::Tasks::MissingForeignKeys.new(printer: printer).run
|
17
|
-
printer.missing_foreign_keys
|
25
|
+
assert_equal({}, run_task)
|
18
26
|
end
|
19
27
|
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
require 'active_record_doctor/tasks/missing_non_null_constraint'
|
4
|
+
|
5
|
+
class ActiveRecordDoctor::Tasks::MissingNonNullConstraintTest < ActiveSupport::TestCase
|
6
|
+
def test_presence_true_and_null_true
|
7
|
+
Temping.create(:users, temporary: false) do
|
8
|
+
validates :name, presence: true
|
9
|
+
|
10
|
+
with_columns do |t|
|
11
|
+
t.string :name, null: true
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
assert_equal({ 'users' => ['name'] }, run_task)
|
16
|
+
end
|
17
|
+
|
18
|
+
def test_presence_true_and_null_false
|
19
|
+
Temping.create(:users, temporary: false) do
|
20
|
+
validates :name, presence: true
|
21
|
+
|
22
|
+
with_columns do |t|
|
23
|
+
t.string :name, null: false
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
assert_equal({}, run_task)
|
28
|
+
end
|
29
|
+
|
30
|
+
def test_presence_false_and_null_true
|
31
|
+
Temping.create(:users, temporary: false) do
|
32
|
+
validates :name, presence: false
|
33
|
+
|
34
|
+
with_columns do |t|
|
35
|
+
t.string :name, null: true
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
assert_equal({}, run_task)
|
40
|
+
end
|
41
|
+
|
42
|
+
def test_presence_false_and_null_false
|
43
|
+
Temping.create(:users, temporary: false) do
|
44
|
+
validates :name, presence: false
|
45
|
+
|
46
|
+
with_columns do |t|
|
47
|
+
t.string :name, null: false
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
assert_equal({}, run_task)
|
52
|
+
end
|
53
|
+
|
54
|
+
def test_presence_true_with_if
|
55
|
+
Temping.create(:users, temporary: false) do
|
56
|
+
validates :name, presence: true, if: -> { false }
|
57
|
+
|
58
|
+
with_columns do |t|
|
59
|
+
t.string :name, null: true
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
assert_equal({}, run_task)
|
64
|
+
end
|
65
|
+
|
66
|
+
def test_presence_true_with_unless
|
67
|
+
Temping.create(:users, temporary: false) do
|
68
|
+
validates :name, presence: true, unless: -> { false }
|
69
|
+
|
70
|
+
with_columns do |t|
|
71
|
+
t.string :name, null: true
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
assert_equal({}, run_task)
|
76
|
+
end
|
77
|
+
|
78
|
+
def test_presence_true_with_allow_nil
|
79
|
+
Temping.create(:users, temporary: false) do
|
80
|
+
validates :name, presence: true, allow_nil: true
|
81
|
+
|
82
|
+
with_columns do |t|
|
83
|
+
t.string :name, null: true
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
assert_equal({}, run_task)
|
88
|
+
end
|
89
|
+
end
|