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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 01e06f004eba174cadece575f77fe83381f53133
4
- data.tar.gz: d835060741b338b07a3f87aef33eff707ef32f98
2
+ SHA256:
3
+ metadata.gz: e519565b3322adffcd6b6a82a3c00289f1ad0169bb411c45292bb97306c31ae4
4
+ data.tar.gz: fc8c79e4ba609b97755a59d9c0b0cc86d7dbd9697bd037ecb045187af0d0d34d
5
5
  SHA512:
6
- metadata.gz: e07f92a2fd4b200fd5d9e0ef50e1b5a516347b8216080071648bca31c6e72683ceb01d85e125002e4aa0cd5f48a8020ca43097488a67906c15be443e68783da4
7
- data.tar.gz: 2faca055adba20d77aac44658adb35d40243f491b533a088e5ca44615d16f36e4cee7deca99b1a37607127c5f2bc276e5dac0aa56c34f0b5aa87b611667afd3e
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
- The only think you need to do is run:
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 |model|
44
- @io.puts(" #{model.name} (the table #{model.table_name} is undefined)")
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.include?(column.name.to_sym) &&
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.null &&
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 has_presence_validator?(model, column)
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.include?(column.name.to_sym)
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
- validator.options[:case_sensitive]
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
- !model.connection.tables.include?(model.table_name)
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
- [offending_models, offending_models.blank?]
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
@@ -1,3 +1,3 @@
1
1
  module ActiveRecordDoctor
2
- VERSION = "1.6.0"
2
+ VERSION = "1.7.0"
3
3
  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