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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +29 -0
  3. data/lib/active_record_doctor.rb +16 -12
  4. data/lib/active_record_doctor/detectors.rb +13 -0
  5. data/lib/active_record_doctor/detectors/base.rb +64 -0
  6. data/lib/active_record_doctor/{tasks → detectors}/extraneous_indexes.rb +11 -7
  7. data/lib/active_record_doctor/{tasks → detectors}/incorrect_boolean_presence_validation.rb +9 -6
  8. data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +71 -0
  9. data/lib/active_record_doctor/{tasks → detectors}/missing_foreign_keys.rb +13 -10
  10. data/lib/active_record_doctor/{tasks → detectors}/missing_non_null_constraint.rb +11 -7
  11. data/lib/active_record_doctor/{tasks → detectors}/missing_presence_validation.rb +11 -8
  12. data/lib/active_record_doctor/{tasks → detectors}/missing_unique_indexes.rb +8 -4
  13. data/lib/active_record_doctor/{tasks → detectors}/undefined_table_references.rb +11 -12
  14. data/lib/active_record_doctor/{tasks → detectors}/unindexed_deleted_at.rb +12 -6
  15. data/lib/active_record_doctor/{tasks → detectors}/unindexed_foreign_keys.rb +13 -10
  16. data/lib/active_record_doctor/printers.rb +3 -1
  17. data/lib/active_record_doctor/printers/io_printer.rb +63 -35
  18. data/lib/active_record_doctor/railtie.rb +2 -0
  19. data/lib/active_record_doctor/task.rb +28 -0
  20. data/lib/active_record_doctor/version.rb +3 -1
  21. data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +15 -11
  22. data/lib/tasks/active_record_doctor.rake +25 -25
  23. data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +67 -0
  24. data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +36 -0
  25. data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +117 -0
  26. data/test/active_record_doctor/detectors/missing_foreign_keys_test.rb +24 -0
  27. data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +102 -0
  28. data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +107 -0
  29. data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +114 -0
  30. data/test/active_record_doctor/detectors/undefined_table_references_test.rb +44 -0
  31. data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +67 -0
  32. data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +26 -0
  33. data/test/active_record_doctor/printers/io_printer_test.rb +14 -9
  34. data/test/model_factory.rb +78 -0
  35. data/test/setup.rb +69 -40
  36. metadata +70 -64
  37. data/lib/active_record_doctor/tasks.rb +0 -10
  38. data/lib/active_record_doctor/tasks/base.rb +0 -86
  39. data/test/active_record_doctor/tasks/extraneous_indexes_test.rb +0 -77
  40. data/test/active_record_doctor/tasks/incorrect_boolean_presence_validation_test.rb +0 -38
  41. data/test/active_record_doctor/tasks/missing_foreign_keys_test.rb +0 -23
  42. data/test/active_record_doctor/tasks/missing_non_null_constraint_test.rb +0 -113
  43. data/test/active_record_doctor/tasks/missing_presence_validation_test.rb +0 -115
  44. data/test/active_record_doctor/tasks/missing_unique_indexes_test.rb +0 -126
  45. data/test/active_record_doctor/tasks/undefined_table_references_test.rb +0 -47
  46. data/test/active_record_doctor/tasks/unindexed_deleted_at_test.rb +0 -59
  47. data/test/active_record_doctor/tasks/unindexed_foreign_keys_test.rb +0 -23
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveRecordDoctor::Detectors::MissingUniqueIndexesTest < Minitest::Test
4
+ def test_missing_unique_index
5
+ create_table(:users) do |t|
6
+ t.string :email
7
+ t.index :email
8
+ end.create_model do
9
+ validates :email, uniqueness: true
10
+ end
11
+
12
+ assert_problems(<<OUTPUT)
13
+ The following indexes should be created to back model-level uniqueness validations:
14
+ users: email
15
+ OUTPUT
16
+ end
17
+
18
+ def test_present_unique_index
19
+ create_table(:users) do |t|
20
+ t.string :email
21
+ t.index :email, unique: true
22
+ end.create_model do
23
+ validates :email, uniqueness: true
24
+ end
25
+
26
+ refute_problems
27
+ end
28
+
29
+ def test_missing_unique_index_with_scope
30
+ create_table(:users) do |t|
31
+ t.string :email
32
+ t.integer :company_id
33
+ t.integer :department_id
34
+ t.index [:company_id, :department_id, :email]
35
+ end.create_model do
36
+ validates :email, uniqueness: { scope: [:company_id, :department_id] }
37
+ end
38
+
39
+ assert_problems(<<OUTPUT)
40
+ The following indexes should be created to back model-level uniqueness validations:
41
+ users: company_id, department_id, email
42
+ OUTPUT
43
+ end
44
+
45
+ def test_present_unique_index_with_scope
46
+ create_table(:users) do |t|
47
+ t.string :email
48
+ t.integer :company_id
49
+ t.integer :department_id
50
+ t.index [:company_id, :department_id, :email], unique: true
51
+ end.create_model do
52
+ validates :email, uniqueness: { scope: [:company_id, :department_id] }
53
+ end
54
+
55
+ refute_problems
56
+ end
57
+
58
+ def test_column_order_is_ignored
59
+ create_table(:users) do |t|
60
+ t.string :email
61
+ t.integer :organization_id
62
+
63
+ t.index [:email, :organization_id], unique: true
64
+ end.create_model do
65
+ validates :email, uniqueness: { scope: :organization_id }
66
+ end
67
+
68
+ refute_problems
69
+ end
70
+
71
+ def test_conditions_is_skipped
72
+ assert_skipped(conditions: -> { where.not(email: nil) })
73
+ end
74
+
75
+ def test_case_insensitive_is_skipped
76
+ assert_skipped(case_sensitive: false)
77
+ end
78
+
79
+ def test_if_is_skipped
80
+ assert_skipped(if: ->(_model) { true })
81
+ end
82
+
83
+ def test_unless_is_skipped
84
+ assert_skipped(unless: ->(_model) { true })
85
+ end
86
+
87
+ def test_skips_validator_without_attributes
88
+ create_table(:users) do |t|
89
+ t.string :email
90
+ t.index :email
91
+ end.create_model do
92
+ validates_with DummyValidator
93
+ end
94
+
95
+ refute_problems
96
+ end
97
+
98
+ class DummyValidator < ActiveModel::Validator
99
+ def validate(record)
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def assert_skipped(options)
106
+ create_table(:users) do |t|
107
+ t.string :email
108
+ end.create_model do
109
+ validates :email, uniqueness: options
110
+ end
111
+
112
+ refute_problems
113
+ end
114
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveRecordDoctor::Detectors::UndefinedTableReferencesTest < Minitest::Test
4
+ def test_table_exists
5
+ create_table(:users) do
6
+ end.create_model do
7
+ end
8
+
9
+ refute_problems
10
+ end
11
+
12
+ def test_table_does_not_exist_when_views_supported
13
+ create_model(:users)
14
+
15
+ if mysql? && ActiveRecord::VERSION::STRING < "5.0"
16
+ assert_problems(<<OUTPUT)
17
+ WARNING: Models backed by database views are supported only in Rails 5+ OR
18
+ Rails 4.2 + PostgreSQL. It seems this is NOT your setup. Therefore, such models
19
+ will be erroneously reported below as not having their underlying tables/views.
20
+ Consider upgrading Rails or disabling this task temporarily.
21
+ The following models reference undefined tables:
22
+ ModelFactory::Models::User (the table users is undefined)
23
+ OUTPUT
24
+ else
25
+ assert_problems(<<OUTPUT)
26
+ The following models reference undefined tables:
27
+ ModelFactory::Models::User (the table users is undefined)
28
+ OUTPUT
29
+ end
30
+ end
31
+
32
+ def test_view_instead_of_table
33
+ # We replace the underlying table with a view. The view doesn't have to be
34
+ # backed by an actual table - it can simply return a predefined tuple.
35
+ ActiveRecord::Base.connection.execute("CREATE VIEW users AS SELECT 1")
36
+ create_model(:users)
37
+
38
+ begin
39
+ refute_problems
40
+ ensure
41
+ ActiveRecord::Base.connection.execute("DROP VIEW users")
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveRecordDoctor::Detectors::UnindexedDeletedAtTest < Minitest::Test
4
+ def test_indexed_deleted_at_is_not_reported
5
+ skip("MySQL doesn't support partial indexes") if mysql?
6
+
7
+ create_table(:users) do |t|
8
+ t.string :first_name
9
+ t.string :last_name
10
+ t.datetime :deleted_at
11
+ t.index [:first_name, :last_name],
12
+ name: "index_profiles_on_first_name_and_last_name",
13
+ where: "deleted_at IS NULL"
14
+ end
15
+
16
+ refute_problems
17
+ end
18
+
19
+ def test_unindexed_deleted_at_is_reported
20
+ skip("MySQL doesn't support partial indexes") if mysql?
21
+
22
+ create_table(:users) do |t|
23
+ t.string :first_name
24
+ t.string :last_name
25
+ t.datetime :deleted_at
26
+ t.index [:first_name, :last_name],
27
+ name: "index_profiles_on_first_name_and_last_name"
28
+ end
29
+
30
+ assert_problems(<<OUTPUT)
31
+ The following indexes should include `deleted_at IS NULL`:
32
+ index_profiles_on_first_name_and_last_name
33
+ OUTPUT
34
+ end
35
+
36
+ def test_indexed_discarded_at_is_not_reported
37
+ skip("MySQL doesn't support partial indexes") if mysql?
38
+
39
+ create_table(:users) do |t|
40
+ t.string :first_name
41
+ t.string :last_name
42
+ t.datetime :discarded_at
43
+ t.index [:first_name, :last_name],
44
+ name: "index_profiles_on_first_name_and_last_name",
45
+ where: "discarded_at IS NULL"
46
+ end
47
+
48
+ refute_problems
49
+ end
50
+
51
+ def test_unindexed_discarded_at_is_reported
52
+ skip("MySQL doesn't support partial indexes") if mysql?
53
+
54
+ create_table(:users) do |t|
55
+ t.string :first_name
56
+ t.string :last_name
57
+ t.datetime :discarded_at
58
+ t.index [:first_name, :last_name],
59
+ name: "index_profiles_on_first_name_and_last_name"
60
+ end
61
+
62
+ assert_problems(<<OUTPUT)
63
+ The following indexes should include `deleted_at IS NULL`:
64
+ index_profiles_on_first_name_and_last_name
65
+ OUTPUT
66
+ end
67
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveRecordDoctor::Detectors::UnindexedForeignKeysTest < Minitest::Test
4
+ def test_unindexed_foreign_key_is_reported
5
+ skip("MySQL always indexes foreign keys") if mysql?
6
+
7
+ create_table(:companies)
8
+ create_table(:users) do |t|
9
+ t.references :company, foreign_key: true, index: false
10
+ end
11
+
12
+ assert_problems(<<OUTPUT)
13
+ The following foreign keys should be indexed for performance reasons:
14
+ users company_id
15
+ OUTPUT
16
+ end
17
+
18
+ def test_indexed_foreign_key_is_not_reported
19
+ create_table(:companies)
20
+ create_table(:users) do |t|
21
+ t.references :company, foreign_key: true, index: true
22
+ end
23
+
24
+ refute_problems
25
+ end
26
+ end
@@ -1,8 +1,10 @@
1
- # Load all tasks
1
+ # frozen_string_literal: true
2
+
3
+ # Load all detectors
2
4
  class ActiveRecordDoctor::Printers::IOPrinterTest < Minitest::Test
3
- def test_all_tasks_have_printers
4
- ActiveRecordDoctor::Tasks::Base.subclasses.each do |task_class|
5
- name = task_class.name.demodulize.underscore.to_sym
5
+ def test_all_detectors_have_printers
6
+ ActiveRecordDoctor::Detectors::Base.subclasses.each do |detector_class|
7
+ name = detector_class.name.demodulize.underscore.to_sym
6
8
 
7
9
  assert(
8
10
  ActiveRecordDoctor::Printers::IOPrinter.method_defined?(name),
@@ -12,17 +14,20 @@ class ActiveRecordDoctor::Printers::IOPrinterTest < Minitest::Test
12
14
  end
13
15
 
14
16
  def test_unindexed_foreign_keys
15
- assert_equal(<<EOF, unindexed_foreign_keys({ "users" => ["profile_id", "account_id"], "account" => ["group_id"] }))
16
- account group_id
17
- users account_id profile_id
18
- EOF
17
+ # rubocop:disable Layout/LineLength
18
+ assert_equal(<<OUTPUT, unindexed_foreign_keys({ "users" => ["profile_id", "account_id"], "account" => ["group_id"] }))
19
+ The following foreign keys should be indexed for performance reasons:
20
+ account group_id
21
+ users account_id profile_id
22
+ OUTPUT
23
+ # rubocop:enable Layout/LineLength
19
24
  end
20
25
 
21
26
  private
22
27
 
23
28
  def unindexed_foreign_keys(argument)
24
29
  io = StringIO.new
25
- ActiveRecordDoctor::Printers::IOPrinter.new(io).unindexed_foreign_keys(argument)
30
+ ActiveRecordDoctor::Printers::IOPrinter.new(io).unindexed_foreign_keys(argument, {})
26
31
  io.string
27
32
  end
28
33
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ModelFactory
4
+ def self.cleanup
5
+ delete_models
6
+ drop_all_tables
7
+ GC.start
8
+ end
9
+
10
+ def self.drop_all_tables
11
+ connection = ActiveRecord::Base.connection
12
+ loop do
13
+ before = connection.tables.size
14
+ break if before.zero?
15
+
16
+ attempt_drop_all_tables(connection)
17
+ after = connection.tables.size
18
+
19
+ if before == after
20
+ raise("cannot delete temporary tables - most likely due to failing constraints")
21
+ end
22
+ end
23
+ end
24
+
25
+ def self.attempt_drop_all_tables(connection)
26
+ connection.tables.each do |table_name|
27
+ ActiveRecord::Migration.suppress_messages do
28
+ begin
29
+ connection.drop_table(table_name, force: :cascade)
30
+ rescue ActiveRecord::InvalidForeignKey, ActiveRecord::StatementInvalid
31
+ # The table cannot be dropped due to foreign key constraints so
32
+ # we'll try to drop it on another attempt.
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ def self.delete_models
39
+ Models.empty
40
+ end
41
+
42
+ def self.create_table(table_name, &block)
43
+ table_name = table_name.to_sym
44
+ ActiveRecord::Migration.suppress_messages do
45
+ ActiveRecord::Migration.create_table(table_name, &block)
46
+ end
47
+
48
+ # Return a proxy object allowing the caller to chain #create_model
49
+ # right after creating a table so that it can be followed by the model
50
+ # definition.
51
+ ModelDefinitionProxy.new(table_name)
52
+ end
53
+
54
+ def self.create_model(table_name, &block)
55
+ table_name = table_name.to_sym
56
+ klass = Class.new(ActiveRecord::Base, &block)
57
+ klass_name = table_name.to_s.classify
58
+ Models.const_set(klass_name, klass)
59
+ end
60
+
61
+ class ModelDefinitionProxy
62
+ def initialize(table_name)
63
+ @table_name = table_name
64
+ end
65
+
66
+ def create_model(&block)
67
+ ModelFactory.create_model(@table_name, &block)
68
+ end
69
+ end
70
+
71
+ module Models
72
+ def self.empty
73
+ constants.each do |name|
74
+ remove_const(name)
75
+ end
76
+ end
77
+ end
78
+ end
data/test/setup.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # Configure Active Record.
2
4
 
3
5
  # We must import "uri" explicitly as otherwsie URI won't be accessible in
@@ -7,15 +9,30 @@ require "uri"
7
9
  require "active_record"
8
10
 
9
11
  # Connect to the database defined in the URL.
10
- ActiveRecord::Base.establish_connection(ENV.fetch("DATABASE_URL"))
12
+ case ENV["DATABASE"]
13
+ when "postgresql"
14
+ require "pg"
15
+ DEFAULT_DATABASE_URL = "postgres:///active_record_doctor_test"
16
+ when "mysql"
17
+ require "mysql2"
18
+ DEFAULT_DATABASE_URL = "mysql2:///active_record_doctor_test"
19
+ when nil
20
+ # rubocop:disable Style/StderrPuts
21
+ $stderr.puts(<<ERROR)
22
+ The DATABASE environment variable is not set. It must be set before running the
23
+ test suite. Valid values are "mysql" and "postgresql".
24
+ ERROR
25
+ # rubocop:enable Style/StderrPuts
26
+ exit(1)
27
+ else raise("unrecognized database #{ENV['DATABASE']}")
28
+ end
29
+ ActiveRecord::Base.establish_connection(ENV.fetch("DATABASE_URL", DEFAULT_DATABASE_URL))
11
30
 
12
31
  # We need to call #connection to enfore Active Record to actually establish
13
32
  # the connection.
14
33
  ActiveRecord::Base.connection
15
34
 
16
-
17
-
18
- # We need to mock Rails because some tasks depend on .eager_load! This must
35
+ # We need to mock Rails because some detectors depend on .eager_load! This must
19
36
  # happen AFTER loading active_record_doctor as otherwise it'd attempt to
20
37
  # install a Railtie.
21
38
  module Rails
@@ -29,61 +46,75 @@ module Rails
29
46
  end
30
47
  end
31
48
 
32
-
33
-
34
49
  # Load Active Record Doctor.
35
50
  require "active_record_doctor"
36
51
 
37
-
38
-
39
52
  # Configure the test suite.
40
53
  require "minitest"
41
54
  require "minitest/autorun"
42
55
  require "minitest/fork_executor"
43
56
 
57
+ require_relative "model_factory"
44
58
 
59
+ # Prepare the test class.
60
+ class Minitest::Test
61
+ def setup
62
+ # Ensure all remnants of previous test runs, most likely in form of tables,
63
+ # are removed.
64
+ ModelFactory.cleanup
65
+ end
45
66
 
46
- # temping is a test library for creating tables and models on the fly. We use
47
- # it instead of a fixed dummy Rails app created by the generator.
48
- require "temping"
67
+ def teardown
68
+ ModelFactory.cleanup
69
+ end
49
70
 
50
- # Temping 3.10.0 is broken because it removes the tables in the order of
51
- # creation which fails if foreign key constraints are present.
52
- class Temping
53
- class << self
54
- alias_method :old_teardown, :teardown
71
+ private
55
72
 
56
- def teardown
57
- @model_klasses.reverse!
58
- old_teardown
73
+ def postgresql?
74
+ ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
75
+ end
59
76
 
60
- # This hack is required to avoid leaking temporary model classes defined
61
- # by Temping. If we don't clear the cache then they'll be kept around and
62
- # returned as valid models which will break tests.
63
- ActiveSupport::DescendantsTracker.class_variable_get(:@@direct_descendants).clear
64
- end
77
+ def mysql?
78
+ ActiveRecord::Base.connection.adapter_name == "Mysql2"
65
79
  end
66
- end
67
80
 
81
+ def create_table(*args, &block)
82
+ ModelFactory.create_table(*args, &block)
83
+ end
68
84
 
85
+ def create_model(*args, &block)
86
+ ModelFactory.create_model(*args, &block)
87
+ end
69
88
 
70
- # Prepare the test class.
71
- class Minitest::Test
72
- def teardown
73
- # Remove temporary databases created by the current test case.
74
- Temping.teardown
89
+ # Return the detector class under test.
90
+ def detector_class
91
+ self.class.name.sub(/Test$/, "").constantize
75
92
  end
76
93
 
77
- private
94
+ # Run the appropriate detector. The detector name is inferred from the test class.
95
+ def run_detector
96
+ detector_class.run.first
97
+ end
78
98
 
79
- # Run the appropriate task. The task name is inferred from the test class.
80
99
  def run_task
81
- self.class.name.sub(/Test$/, '').constantize.run.first
100
+ output = StringIO.new
101
+ printer = ActiveRecordDoctor::Printers::IOPrinter.new(output)
102
+ success = ActiveRecordDoctor::Task.new(detector_class, printer).run
103
+ [success, output.string]
82
104
  end
83
105
 
84
- # Assert results are equal without regards to the order of elements.
85
- def assert_result(expected_result)
86
- assert_equal(expected_result.sort_by(&:to_s), run_task.sort_by(&:to_s))
106
+ def assert_problems(expected_output)
107
+ success, actual_output = run_task
108
+
109
+ assert_equal(expected_output, actual_output)
110
+ refute(success)
111
+ end
112
+
113
+ def refute_problems
114
+ success, actual_output = run_task
115
+
116
+ assert_equal("", actual_output)
117
+ assert(success)
87
118
  end
88
119
  end
89
120
 
@@ -91,7 +122,5 @@ end
91
122
  # to be shown.
92
123
  Minitest.backtrace_filter = Minitest::BacktraceFilter.new
93
124
 
94
- # Run each test method in a separate process so that we avoid leaking
95
- # temporary models defined by temping. I'm not entirely sure but it seems to
96
- # be a problem with Rails caching those classes aggressively.
97
- # Minitest.parallel_executor = Minitest::ForkExecutor.new
125
+ # Uncomment in case there's test case interference.
126
+ Minitest.parallel_executor = Minitest::ForkExecutor.new