active_record_doctor 1.6.0 → 1.7.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 +5 -5
- data/README.md +28 -1
- data/lib/active_record_doctor/printers/io_printer.rb +21 -3
- data/lib/active_record_doctor/tasks/base.rb +14 -0
- data/lib/active_record_doctor/tasks/incorrect_boolean_presence_validation.rb +33 -0
- data/lib/active_record_doctor/tasks/missing_non_null_constraint.rb +8 -1
- data/lib/active_record_doctor/tasks/missing_presence_validation.rb +37 -5
- data/lib/active_record_doctor/tasks/missing_unique_indexes.rb +6 -1
- data/lib/active_record_doctor/tasks/undefined_table_references.rb +17 -2
- data/lib/active_record_doctor/version.rb +1 -1
- data/lib/tasks/active_record_doctor.rake +1 -0
- data/test/active_record_doctor/tasks/incorrect_boolean_presence_validation_test.rb +33 -0
- data/test/active_record_doctor/tasks/missing_non_null_constraint_test.rb +13 -0
- data/test/active_record_doctor/tasks/missing_presence_validation_test.rb +61 -0
- data/test/active_record_doctor/tasks/undefined_table_references_test.rb +51 -0
- data/test/dummy/app/assets/config/manifest.js +1 -0
- data/test/dummy/log/development.log +39 -0
- data/test/dummy/log/test.log +12272 -0
- data/test/test_helper.rb +4 -0
- metadata +29 -10
- data/test/support/forking_test.rb +0 -28
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: e519565b3322adffcd6b6a82a3c00289f1ad0169bb411c45292bb97306c31ae4
|
4
|
+
data.tar.gz: fc8c79e4ba609b97755a59d9c0b0cc86d7dbd9697bd037ecb045187af0d0d34d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1a9e7ecb7a194938ab9cb9540dfd6d19a6b8092b23da2374bafa2146164184d29e3fd3877aef6c6709e531c514055bf2bf6be1a268bb4023bb16889f89f08f17
|
7
|
+
data.tar.gz: 545dc4d6ae159b3619d0a0712d31e09aa40ea8ddc104401801791b4d74f3fae59b6cfde609cebea6d5718755f2b488c754524335e53bcf2d9e32a9d7242a6e60
|
data/README.md
CHANGED
@@ -11,6 +11,7 @@ can:
|
|
11
11
|
* detect uniqueness validations not backed by an unique index
|
12
12
|
* detect missing non-`NULL` constraints
|
13
13
|
* detect missing presence validations
|
14
|
+
* detect incorrect presence validations on boolean columns
|
14
15
|
|
15
16
|
More features coming soon!
|
16
17
|
|
@@ -170,7 +171,12 @@ cases where the name can be wrong (e.g. you forgot to commit a migration or
|
|
170
171
|
changed the table name). Active Record Doctor can help you identify these cases
|
171
172
|
before they hit production.
|
172
173
|
|
173
|
-
|
174
|
+
**IMPORTANT**. Models backed by views are supported only in:
|
175
|
+
|
176
|
+
* Rails 5+ and _any_ database or
|
177
|
+
* Rails 4.2 with PostgreSQL.
|
178
|
+
|
179
|
+
The only think you need to do is run:
|
174
180
|
|
175
181
|
```
|
176
182
|
bundle exec rake active_record_doctor:undefined_table_references
|
@@ -251,6 +257,27 @@ The following models and columns should have presence validations:
|
|
251
257
|
|
252
258
|
This means `User` should have a presence validator on `email` and `name`.
|
253
259
|
|
260
|
+
### Detecting Incorrect Presence Validations on Boolean Columns
|
261
|
+
|
262
|
+
A boolean column's presence should be validated using inclusion or exclusion
|
263
|
+
validators instead of the usual presence validator.
|
264
|
+
|
265
|
+
In order to detect boolean columns whose presence is validated incorrectly run:
|
266
|
+
|
267
|
+
```
|
268
|
+
bundle exec rake active_record_doctor:incorrect_boolean_presence_validation
|
269
|
+
```
|
270
|
+
|
271
|
+
The output of the command looks like this:
|
272
|
+
|
273
|
+
```
|
274
|
+
The presence of the following boolean columns is validated incorrectly:
|
275
|
+
User: active
|
276
|
+
```
|
277
|
+
|
278
|
+
This means `active` is validated with `presence: true` instead of
|
279
|
+
`inclusion: { in: [true, false] }` or `exclusion: { in: [nil] }`.
|
280
|
+
|
254
281
|
## Author
|
255
282
|
|
256
283
|
This gem is developed and maintained by [Greg Navis](http://www.gregnavis.com).
|
@@ -36,12 +36,21 @@ module ActiveRecordDoctor
|
|
36
36
|
end.join("\n"))
|
37
37
|
end
|
38
38
|
|
39
|
-
def undefined_table_references(models)
|
39
|
+
def undefined_table_references((models, views_checked))
|
40
40
|
return if models.empty?
|
41
41
|
|
42
|
+
unless views_checked
|
43
|
+
@io.puts(<<EOS)
|
44
|
+
WARNING: Models backed by database views are supported only in Rails 5+ OR
|
45
|
+
Rails 4.2 + PostgreSQL. It seems this is NOT your setup. Therefore, such models
|
46
|
+
will be erroneously reported below as not having their underlying tables/views.
|
47
|
+
Consider upgrading Rails or disabling this task temporarily.
|
48
|
+
EOS
|
49
|
+
end
|
50
|
+
|
42
51
|
@io.puts('The following models reference undefined tables:')
|
43
|
-
models.each do |
|
44
|
-
@io.puts(" #{
|
52
|
+
models.each do |model_name, table_name|
|
53
|
+
@io.puts(" #{model_name} (the table #{table_name} is undefined)")
|
45
54
|
end
|
46
55
|
end
|
47
56
|
|
@@ -82,6 +91,15 @@ module ActiveRecordDoctor
|
|
82
91
|
@io.puts(" #{table}: #{columns.join(', ')}")
|
83
92
|
end
|
84
93
|
end
|
94
|
+
|
95
|
+
def presence_true_on_boolean(presence_true_on_booleans)
|
96
|
+
return if presence_true_on_booleans.empty?
|
97
|
+
|
98
|
+
@io.puts('The presence of the following boolean columns is validated incorrectly:')
|
99
|
+
presence_true_on_booleans.each do |table, columns|
|
100
|
+
@io.puts(" #{table}: #{columns.join(', ')}")
|
101
|
+
end
|
102
|
+
end
|
85
103
|
end
|
86
104
|
end
|
87
105
|
end
|
@@ -34,6 +34,20 @@ module ActiveRecordDoctor
|
|
34
34
|
end
|
35
35
|
end
|
36
36
|
|
37
|
+
def views
|
38
|
+
@views ||=
|
39
|
+
if Rails::VERSION::MAJOR == 5
|
40
|
+
connection.views
|
41
|
+
elsif connection.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter)
|
42
|
+
ActiveRecord::Base.connection.execute(<<-SQL).map { |tuple| tuple.fetch("relname") }
|
43
|
+
SELECT c.relname FROM pg_class c WHERE c.relkind IN ('m', 'v')
|
44
|
+
SQL
|
45
|
+
else
|
46
|
+
# We don't support this Rails/database combination yet.
|
47
|
+
nil
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
37
51
|
def hash_from_pairs(pairs)
|
38
52
|
Hash[*pairs.flatten(1)]
|
39
53
|
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require "active_record_doctor/tasks/base"
|
2
|
+
|
3
|
+
module ActiveRecordDoctor
|
4
|
+
module Tasks
|
5
|
+
class IncorrectBooleanPresenceValidation < 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
|
+
column.type == :boolean &&
|
16
|
+
has_presence_validator?(model, column)
|
17
|
+
end.map(&:name)
|
18
|
+
]
|
19
|
+
end.reject do |model_name, columns|
|
20
|
+
columns.empty?
|
21
|
+
end))
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def has_presence_validator?(model, column)
|
27
|
+
model.validators.any? do |validator|
|
28
|
+
validator.kind == :presence && validator.attributes.include?(column.name.to_sym)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -29,9 +29,16 @@ module ActiveRecordDoctor
|
|
29
29
|
end
|
30
30
|
|
31
31
|
def has_mandatory_presence_validator?(model, column)
|
32
|
+
allowed_attributes = [column.name.to_sym]
|
33
|
+
|
34
|
+
belongs_to = model.reflect_on_all_associations(:belongs_to).find do |reflection|
|
35
|
+
reflection.foreign_key == column.name
|
36
|
+
end
|
37
|
+
allowed_attributes << belongs_to.name.to_sym if belongs_to
|
38
|
+
|
32
39
|
model.validators.any? do |validator|
|
33
40
|
validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
|
34
|
-
validator.attributes.
|
41
|
+
(validator.attributes && allowed_attributes).present? &&
|
35
42
|
!validator.options[:allow_nil] &&
|
36
43
|
!validator.options[:if] &&
|
37
44
|
!validator.options[:unless]
|
@@ -13,8 +13,7 @@ module ActiveRecordDoctor
|
|
13
13
|
model.name,
|
14
14
|
connection.columns(model.table_name).select do |column|
|
15
15
|
validator_needed?(model, column) &&
|
16
|
-
!column
|
17
|
-
!has_presence_validator?(model, column)
|
16
|
+
!validator_present?(model, column)
|
18
17
|
end.map(&:name)
|
19
18
|
]
|
20
19
|
end.reject do |model_name, columns|
|
@@ -25,13 +24,46 @@ module ActiveRecordDoctor
|
|
25
24
|
private
|
26
25
|
|
27
26
|
def validator_needed?(model, column)
|
28
|
-
![model.primary_key, 'created_at', 'updated_at'].include?(column.name)
|
27
|
+
![model.primary_key, 'created_at', 'updated_at'].include?(column.name) &&
|
28
|
+
!column.null
|
29
29
|
end
|
30
30
|
|
31
|
-
def
|
31
|
+
def validator_present?(model, column)
|
32
|
+
if column.type == :boolean
|
33
|
+
inclusion_validator_present?(model, column) ||
|
34
|
+
exclusion_validator_present?(model, column)
|
35
|
+
else
|
36
|
+
presence_validator_present?(model, column)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def inclusion_validator_present?(model, column)
|
41
|
+
model.validators.any? do |validator|
|
42
|
+
validator.is_a?(ActiveModel::Validations::InclusionValidator) &&
|
43
|
+
validator.attributes.include?(column.name.to_sym) &&
|
44
|
+
!validator.options.fetch(:in, []).include?(nil)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def exclusion_validator_present?(model, column)
|
49
|
+
model.validators.any? do |validator|
|
50
|
+
validator.is_a?(ActiveModel::Validations::ExclusionValidator) &&
|
51
|
+
validator.attributes.include?(column.name.to_sym) &&
|
52
|
+
validator.options.fetch(:in, []).include?(nil)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def presence_validator_present?(model, column)
|
57
|
+
allowed_attributes = [column.name.to_sym]
|
58
|
+
|
59
|
+
belongs_to = model.reflect_on_all_associations(:belongs_to).find do |reflection|
|
60
|
+
reflection.foreign_key == column.name
|
61
|
+
end
|
62
|
+
allowed_attributes << belongs_to.name.to_sym if belongs_to
|
63
|
+
|
32
64
|
model.validators.any? do |validator|
|
33
65
|
validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
|
34
|
-
validator.attributes.
|
66
|
+
(validator.attributes & allowed_attributes).present?
|
35
67
|
end
|
36
68
|
end
|
37
69
|
end
|
@@ -36,7 +36,12 @@ module ActiveRecordDoctor
|
|
36
36
|
validator.options[:if].nil? &&
|
37
37
|
validator.options[:unless].nil? &&
|
38
38
|
validator.options[:conditions].nil? &&
|
39
|
-
|
39
|
+
|
40
|
+
# In Rails 6, default option values are no longer explicitly set on
|
41
|
+
# options so if the key is absent we must fetch the default value
|
42
|
+
# ourselves. case_sensitive is the default in 4.2+ so it's safe to
|
43
|
+
# put true literally.
|
44
|
+
validator.options.fetch(:case_sensitive, true)
|
40
45
|
end
|
41
46
|
|
42
47
|
def unique_index?(table_name, columns, scope)
|
@@ -6,12 +6,27 @@ module ActiveRecordDoctor
|
|
6
6
|
def run
|
7
7
|
eager_load!
|
8
8
|
|
9
|
+
# If we can't list views due to old Rails version or unsupported
|
10
|
+
# database then existing_views is nil. We inform the caller we haven't
|
11
|
+
# consulted views so that it can display an appropriate warning.
|
12
|
+
existing_views = views
|
13
|
+
|
9
14
|
offending_models = models.select do |model|
|
10
15
|
model.table_name.present? &&
|
11
|
-
!
|
16
|
+
!tables.include?(model.table_name) &&
|
17
|
+
existing_views &&
|
18
|
+
!existing_views.include?(model.table_name)
|
19
|
+
end.map do |model|
|
20
|
+
[model.name, model.table_name]
|
12
21
|
end
|
13
22
|
|
14
|
-
[
|
23
|
+
[
|
24
|
+
[
|
25
|
+
offending_models, # Actual results
|
26
|
+
!existing_views.nil? # true if views were checked, false otherwise
|
27
|
+
],
|
28
|
+
offending_models.blank?
|
29
|
+
]
|
15
30
|
end
|
16
31
|
end
|
17
32
|
end
|
@@ -7,6 +7,7 @@ require "active_record_doctor/tasks/unindexed_deleted_at"
|
|
7
7
|
require "active_record_doctor/tasks/missing_unique_indexes"
|
8
8
|
require "active_record_doctor/tasks/missing_presence_validation"
|
9
9
|
require "active_record_doctor/tasks/missing_non_null_constraint"
|
10
|
+
require "active_record_doctor/tasks/incorrect_boolean_presence_validation"
|
10
11
|
|
11
12
|
namespace :active_record_doctor do
|
12
13
|
def mount(task_class)
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
require 'active_record_doctor/tasks/incorrect_boolean_presence_validation'
|
4
|
+
|
5
|
+
class ActiveRecordDoctor::Tasks::IncorrectBooleanPresenceValidationTest < ActiveSupport::TestCase
|
6
|
+
def test_presence_true_is_reported_on_boolean_only
|
7
|
+
Temping.create(:users, temporary: false) do
|
8
|
+
# email is a non-boolean column whose presence CAN be validated in the
|
9
|
+
# usual way. We include it in the test model to ensure the task reports
|
10
|
+
# only boolean columns.
|
11
|
+
validates :email, :active, presence: true
|
12
|
+
|
13
|
+
with_columns do |t|
|
14
|
+
t.string :email, null: false
|
15
|
+
t.boolean :active, null: false
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
assert_equal({ 'User' => ['active'] }, run_task)
|
20
|
+
end
|
21
|
+
|
22
|
+
def test_inclusion_is_not_reported
|
23
|
+
Temping.create(:users, temporary: false) do
|
24
|
+
validates :active, inclusion: { in: [true, false] }
|
25
|
+
|
26
|
+
with_columns do |t|
|
27
|
+
t.boolean :active, null: false
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
assert_equal({}, run_task)
|
32
|
+
end
|
33
|
+
end
|
@@ -15,6 +15,19 @@ class ActiveRecordDoctor::Tasks::MissingNonNullConstraintTest < ActiveSupport::T
|
|
15
15
|
assert_equal({ 'users' => ['name'] }, run_task)
|
16
16
|
end
|
17
17
|
|
18
|
+
def test_association_presence_true_and_null_true
|
19
|
+
Temping.create(:companies, temporary: false)
|
20
|
+
Temping.create(:users, temporary: false) do
|
21
|
+
belongs_to :company, required: true
|
22
|
+
|
23
|
+
with_columns do |t|
|
24
|
+
t.references :company
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
assert_equal({ 'users' => ['company_id'] }, run_task)
|
29
|
+
end
|
30
|
+
|
18
31
|
def test_presence_true_and_null_false
|
19
32
|
Temping.create(:users, temporary: false) do
|
20
33
|
validates :name, presence: true
|
@@ -35,6 +35,67 @@ class ActiveRecordDoctor::Tasks::MissingPresenceValidationTest < ActiveSupport::
|
|
35
35
|
assert_equal({}, run_task)
|
36
36
|
end
|
37
37
|
|
38
|
+
def test_non_null_column_is_not_reported_if_association_validation_present
|
39
|
+
Temping.create(:companies, temporary: false)
|
40
|
+
Temping.create(:users, temporary: false) do
|
41
|
+
belongs_to :company, required: true
|
42
|
+
|
43
|
+
with_columns do |t|
|
44
|
+
t.references :company, null: false
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
assert_equal({}, run_task)
|
49
|
+
end
|
50
|
+
|
51
|
+
def test_non_null_boolean_is_reported_if_nil_included
|
52
|
+
Temping.create(:users, temporary: false) do
|
53
|
+
validates :active, inclusion: { in: [nil, true, false] }
|
54
|
+
|
55
|
+
with_columns do |t|
|
56
|
+
t.boolean :active, null: false
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
assert_equal({ 'User' => ['active'] }, run_task)
|
61
|
+
end
|
62
|
+
|
63
|
+
def test_non_null_boolean_is_not_reported_if_nil_not_included
|
64
|
+
Temping.create(:users, temporary: false) do
|
65
|
+
validates :active, inclusion: { in: [true, false] }
|
66
|
+
|
67
|
+
with_columns do |t|
|
68
|
+
t.boolean :active, null: false
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
assert_equal({}, run_task)
|
73
|
+
end
|
74
|
+
|
75
|
+
def test_non_null_boolean_is_not_reported_if_nil_excluded
|
76
|
+
Temping.create(:users, temporary: false) do
|
77
|
+
validates :active, exclusion: { in: [nil] }
|
78
|
+
|
79
|
+
with_columns do |t|
|
80
|
+
t.boolean :active, null: false
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
assert_equal({}, run_task)
|
85
|
+
end
|
86
|
+
|
87
|
+
def test_non_null_boolean_is_reported_if_nil_not_excluded
|
88
|
+
Temping.create(:users, temporary: false) do
|
89
|
+
validates :active, exclusion: { in: [false] }
|
90
|
+
|
91
|
+
with_columns do |t|
|
92
|
+
t.boolean :active, null: false
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
assert_equal({ 'User' => ['active'] }, run_task)
|
97
|
+
end
|
98
|
+
|
38
99
|
def test_timestamps_are_not_reported
|
39
100
|
Temping.create(:users, temporary: false) do
|
40
101
|
validates :name, presence: true
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
require 'active_record_doctor/tasks/undefined_table_references'
|
4
|
+
|
5
|
+
class ActiveRecordDoctor::Tasks::UndefinedTableReferencesTest < ActiveSupport::TestCase
|
6
|
+
def test_table_exists
|
7
|
+
# No columns needed, just the table.
|
8
|
+
Temping.create(:users, temporary: false)
|
9
|
+
|
10
|
+
assert_equal([[], true], run_task)
|
11
|
+
end
|
12
|
+
|
13
|
+
def test_table_does_not_exist
|
14
|
+
# No columns needed, just the table.
|
15
|
+
Temping.create(:users, temporary: false)
|
16
|
+
|
17
|
+
# We drop the underlying table to make the model invalid.
|
18
|
+
ActiveRecord::Base.connection.drop_table(User.table_name)
|
19
|
+
|
20
|
+
# We wrap the assertion in begin/ensure because we must recreate the
|
21
|
+
# table as otherwise Temping will raise an error. Assertion errors are
|
22
|
+
# signalled via exceptions which we shouldn't swallow if we don't want to
|
23
|
+
# break the test suite hence the choice of begin/ensure.
|
24
|
+
begin
|
25
|
+
assert_equal([[[User.name, User.table_name]], true], run_task)
|
26
|
+
ensure
|
27
|
+
ActiveRecord::Base.connection.create_table(User.table_name)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def test_view_instead_of_table
|
32
|
+
# No columns needed, just the table.
|
33
|
+
Temping.create(:users, temporary: false)
|
34
|
+
|
35
|
+
# We replace the underlying table with a view. The view doesn't have to be
|
36
|
+
# backed by an actual table - it can simply return a predefined tuple.
|
37
|
+
ActiveRecord::Base.connection.drop_table(User.table_name)
|
38
|
+
ActiveRecord::Base.connection.execute("CREATE VIEW users AS SELECT 1")
|
39
|
+
|
40
|
+
# We wrap the assertion in begin/ensure because we must recreate the
|
41
|
+
# table as otherwise Temping will raise an error. Assertion errors are
|
42
|
+
# signalled via exceptions which we shouldn't swallow if we don't want to
|
43
|
+
# break the test suite hence the choice of begin/ensure.
|
44
|
+
begin
|
45
|
+
assert_equal([[], true], run_task)
|
46
|
+
ensure
|
47
|
+
ActiveRecord::Base.connection.execute("DROP VIEW users")
|
48
|
+
ActiveRecord::Base.connection.create_table(User.table_name)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|