active_record_doctor 1.12.0 → 1.13.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -0
  3. data/lib/active_record_doctor/config/loader.rb +1 -1
  4. data/lib/active_record_doctor/detectors/base.rb +11 -7
  5. data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +1 -1
  6. data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +9 -4
  7. data/lib/active_record_doctor/detectors/mismatched_foreign_key_type.rb +1 -1
  8. data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +2 -2
  9. data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +16 -7
  10. data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +1 -0
  11. data/lib/active_record_doctor/logger/hierarchical.rb +1 -1
  12. data/lib/active_record_doctor/railtie.rb +1 -1
  13. data/lib/active_record_doctor/runner.rb +1 -1
  14. data/lib/active_record_doctor/utils.rb +2 -2
  15. data/lib/active_record_doctor/version.rb +1 -1
  16. data/lib/tasks/active_record_doctor.rake +2 -2
  17. metadata +12 -49
  18. data/test/active_record_doctor/config/loader_test.rb +0 -120
  19. data/test/active_record_doctor/config_test.rb +0 -116
  20. data/test/active_record_doctor/detectors/disable_test.rb +0 -30
  21. data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +0 -277
  22. data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +0 -79
  23. data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +0 -511
  24. data/test/active_record_doctor/detectors/incorrect_length_validation_test.rb +0 -107
  25. data/test/active_record_doctor/detectors/mismatched_foreign_key_type_test.rb +0 -116
  26. data/test/active_record_doctor/detectors/missing_foreign_keys_test.rb +0 -70
  27. data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +0 -273
  28. data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +0 -232
  29. data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +0 -496
  30. data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +0 -77
  31. data/test/active_record_doctor/detectors/undefined_table_references_test.rb +0 -55
  32. data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +0 -177
  33. data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +0 -116
  34. data/test/active_record_doctor/runner_test.rb +0 -41
  35. data/test/generators/active_record_doctor/add_indexes/add_indexes_generator_test.rb +0 -141
  36. data/test/setup.rb +0 -124
@@ -1,177 +0,0 @@
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
- t.index [:last_name],
15
- name: "index_deleted_profiles_on_last_name",
16
- where: "deleted_at IS NOT NULL"
17
- end
18
-
19
- refute_problems
20
- end
21
-
22
- def test_unindexed_deleted_at_is_reported
23
- skip("MySQL doesn't support partial indexes") if mysql?
24
-
25
- create_table(:users) do |t|
26
- t.string :first_name
27
- t.string :last_name
28
- t.datetime :deleted_at
29
- t.index [:first_name, :last_name],
30
- name: "index_profiles_on_first_name_and_last_name"
31
- end
32
-
33
- assert_problems(<<~OUTPUT)
34
- consider adding `WHERE deleted_at IS NULL` or `WHERE deleted_at IS NOT NULL` to index_profiles_on_first_name_and_last_name - a partial index can speed lookups of soft-deletable models
35
- OUTPUT
36
- end
37
-
38
- def test_indexed_discarded_at_is_not_reported
39
- skip("MySQL doesn't support partial indexes") if mysql?
40
-
41
- create_table(:users) do |t|
42
- t.string :first_name
43
- t.string :last_name
44
- t.datetime :discarded_at
45
- t.index [:first_name, :last_name],
46
- name: "index_profiles_on_first_name_and_last_name",
47
- where: "discarded_at IS NULL"
48
- t.index [:last_name],
49
- name: "index_discarded_profiles_on_last_name",
50
- where: "discarded_at IS NOT NULL"
51
- end
52
-
53
- refute_problems
54
- end
55
-
56
- def test_unindexed_discarded_at_is_reported
57
- skip("MySQL doesn't support partial indexes") if mysql?
58
-
59
- create_table(:users) do |t|
60
- t.string :first_name
61
- t.string :last_name
62
- t.datetime :discarded_at
63
- t.index [:first_name, :last_name],
64
- name: "index_profiles_on_first_name_and_last_name"
65
- end
66
-
67
- assert_problems(<<~OUTPUT)
68
- consider adding `WHERE discarded_at IS NULL` or `WHERE discarded_at IS NOT NULL` to index_profiles_on_first_name_and_last_name - a partial index can speed lookups of soft-deletable models
69
- OUTPUT
70
- end
71
-
72
- def test_config_ignore_tables
73
- skip("MySQL doesn't support partial indexes") if mysql?
74
-
75
- create_table(:users) do |t|
76
- t.string :first_name
77
- t.string :last_name
78
- t.datetime :discarded_at
79
- t.index [:first_name, :last_name],
80
- name: "index_profiles_on_first_name_and_last_name"
81
- end
82
-
83
- config_file(<<-CONFIG)
84
- ActiveRecordDoctor.configure do |config|
85
- config.detector :unindexed_deleted_at,
86
- ignore_tables: ["users"]
87
- end
88
- CONFIG
89
-
90
- refute_problems
91
- end
92
-
93
- def test_global_ignore_tables
94
- skip("MySQL doesn't support partial indexes") if mysql?
95
-
96
- create_table(:users) do |t|
97
- t.string :first_name
98
- t.string :last_name
99
- t.datetime :discarded_at
100
- t.index [:first_name, :last_name],
101
- name: "index_profiles_on_first_name_and_last_name"
102
- end
103
-
104
- config_file(<<-CONFIG)
105
- ActiveRecordDoctor.configure do |config|
106
- config.global :ignore_tables, ["users"]
107
- end
108
- CONFIG
109
-
110
- refute_problems
111
- end
112
-
113
- def test_config_ignore_columns
114
- skip("MySQL doesn't support partial indexes") if mysql?
115
-
116
- create_table(:users) do |t|
117
- t.string :first_name
118
- t.string :last_name
119
- t.datetime :discarded_at
120
- t.index [:first_name, :last_name],
121
- name: "index_profiles_on_first_name_and_last_name"
122
- end
123
-
124
- config_file(<<-CONFIG)
125
- ActiveRecordDoctor.configure do |config|
126
- config.detector :unindexed_deleted_at,
127
- ignore_columns: ["users.discarded_at"]
128
- end
129
- CONFIG
130
-
131
- refute_problems
132
- end
133
-
134
- def test_config_ignore_indexes
135
- skip("MySQL doesn't support partial indexes") if mysql?
136
-
137
- create_table(:users) do |t|
138
- t.string :first_name
139
- t.string :last_name
140
- t.datetime :discarded_at
141
- t.index [:first_name, :last_name],
142
- name: "index_profiles_on_first_name_and_last_name"
143
- end
144
-
145
- config_file(<<-CONFIG)
146
- ActiveRecordDoctor.configure do |config|
147
- config.detector :unindexed_deleted_at,
148
- ignore_indexes: ["index_profiles_on_first_name_and_last_name"]
149
- end
150
- CONFIG
151
-
152
- refute_problems
153
- end
154
-
155
- def test_config_column_names
156
- skip("MySQL doesn't support partial indexes") if mysql?
157
-
158
- create_table(:users) do |t|
159
- t.string :first_name
160
- t.string :last_name
161
- t.datetime :obliverated_at
162
- t.index [:first_name, :last_name],
163
- name: "index_profiles_on_first_name_and_last_name"
164
- end
165
-
166
- config_file(<<-CONFIG)
167
- ActiveRecordDoctor.configure do |config|
168
- config.detector :unindexed_deleted_at,
169
- column_names: ["obliverated_at"]
170
- end
171
- CONFIG
172
-
173
- assert_problems(<<~OUTPUT)
174
- consider adding `WHERE obliverated_at IS NULL` or `WHERE obliverated_at IS NOT NULL` to index_profiles_on_first_name_and_last_name - a partial index can speed lookups of soft-deletable models
175
- OUTPUT
176
- end
177
- end
@@ -1,116 +0,0 @@
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
- add an index on users(company_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
14
- OUTPUT
15
- end
16
-
17
- def test_unindexed_foreign_key_with_nonstandard_name_is_reported
18
- skip("MySQL always indexes foreign keys") if mysql?
19
-
20
- create_table(:companies)
21
- create_table(:users) do |t|
22
- t.integer :company
23
- t.foreign_key :companies, column: :company
24
- end
25
-
26
- assert_problems(<<~OUTPUT)
27
- add an index on users(company) - foreign keys are often used in database lookups and should be indexed for performance reasons
28
- OUTPUT
29
- end
30
-
31
- def test_unindexed_polymorphic_foreign_key_is_reported
32
- create_table(:notes) do |t|
33
- t.integer :notable_id
34
- t.string :notable_type
35
- end
36
-
37
- assert_problems(<<~OUTPUT)
38
- add an index on notes(notable_type, notable_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
39
- OUTPUT
40
- end
41
-
42
- def test_indexed_polymorphic_foreign_key_is_not_reported
43
- create_table(:notes) do |t|
44
- t.string :title
45
- t.integer :notable_id
46
- t.string :notable_type
47
-
48
- # Includes additional column except `notable`
49
- t.index [:notable_type, :notable_id, :title]
50
- end
51
-
52
- refute_problems
53
- end
54
-
55
- def test_indexed_foreign_key_is_not_reported
56
- create_table(:companies)
57
- create_table(:users) do |t|
58
- t.references :company, foreign_key: true, index: true
59
- end
60
-
61
- refute_problems
62
- end
63
-
64
- def test_config_ignore_tables
65
- skip("MySQL always indexes foreign keys") if mysql?
66
-
67
- create_table(:companies)
68
- create_table(:users) do |t|
69
- t.references :company, foreign_key: true, index: false
70
- end
71
-
72
- config_file(<<-CONFIG)
73
- ActiveRecordDoctor.configure do |config|
74
- config.detector :unindexed_foreign_keys,
75
- ignore_tables: ["users"]
76
- end
77
- CONFIG
78
-
79
- refute_problems
80
- end
81
-
82
- def test_global_ignore_tables
83
- skip("MySQL always indexes foreign keys") if mysql?
84
-
85
- create_table(:companies)
86
- create_table(:users) do |t|
87
- t.references :company, foreign_key: true, index: false
88
- end
89
-
90
- config_file(<<-CONFIG)
91
- ActiveRecordDoctor.configure do |config|
92
- config.global :ignore_tables, ["users"]
93
- end
94
- CONFIG
95
-
96
- refute_problems
97
- end
98
-
99
- def test_config_ignore_columns
100
- skip("MySQL always indexes foreign keys") if mysql?
101
-
102
- create_table(:companies)
103
- create_table(:users) do |t|
104
- t.references :company, foreign_key: true, index: false
105
- end
106
-
107
- config_file(<<-CONFIG)
108
- ActiveRecordDoctor.configure do |config|
109
- config.detector :unindexed_foreign_keys,
110
- ignore_columns: ["users.company_id"]
111
- end
112
- CONFIG
113
-
114
- refute_problems
115
- end
116
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class ActiveRecordDoctor::RunnerTest < Minitest::Test
4
- def setup
5
- @io = StringIO.new
6
- @runner = ActiveRecordDoctor::Runner.new(
7
- config: load_config,
8
- logger: ActiveRecordDoctor::Logger::Dummy.new,
9
- io: @io
10
- )
11
- end
12
-
13
- def test_run_one_raises_on_unknown_detectors
14
- assert_raises(KeyError) do
15
- @runner.run_one(:performance_issues)
16
- end
17
- end
18
-
19
- def test_run_all_returns_true_when_no_errors
20
- assert(@runner.run_all)
21
- assert(@io.string.blank?)
22
- end
23
-
24
- def test_run_all_returns_false_when_errors
25
- # Create a model without its underlying table to trigger an error.
26
- define_model(:User)
27
-
28
- refute(@runner.run_all)
29
- refute(@io.string.blank?)
30
- end
31
-
32
- def test_help_prints_help
33
- ActiveRecordDoctor.detectors.each do |name, _|
34
- @io.truncate(0)
35
-
36
- @runner.help(name)
37
-
38
- refute(@io.string.blank?, "expected help for #{name}")
39
- end
40
- end
41
- end
@@ -1,141 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "rails/generators"
4
-
5
- require "generators/active_record_doctor/add_indexes/add_indexes_generator"
6
-
7
- class ActiveRecordDoctor::AddIndexesGeneratorTest < Minitest::Test
8
- TIMESTAMP = Time.new(2021, 2, 1, 13, 15, 30)
9
-
10
- def test_create_migrations
11
- create_table(:notes) do |t|
12
- t.integer :notable_id, null: false
13
- t.string :notable_type, null: false
14
- end
15
- create_table(:users) do |t|
16
- t.integer :organization_id, null: false
17
- t.integer :account_id, null: false
18
- end
19
- create_table(:organizations) do |t|
20
- t.integer :owner_id
21
- end
22
-
23
- Dir.mktmpdir do |dir|
24
- Dir.chdir(dir)
25
-
26
- path = File.join(dir, "indexes.txt")
27
- File.write(path, <<~INDEXES)
28
- add an index on users(organization_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
29
- add an index on users(account_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
30
- add an index on organizations(owner_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
31
- add an index on notes(notable_type, notable_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
32
- INDEXES
33
-
34
- capture_io do
35
- Time.stub(:now, TIMESTAMP) do
36
- ActiveRecordDoctor::AddIndexesGenerator.start([path])
37
-
38
- load(File.join("db", "migrate", "20210201131530_index_foreign_keys_in_users.rb"))
39
- IndexForeignKeysInUsers.migrate(:up)
40
-
41
- load(File.join("db", "migrate", "20210201131531_index_foreign_keys_in_organizations.rb"))
42
- IndexForeignKeysInOrganizations.migrate(:up)
43
-
44
- load(File.join("db", "migrate", "20210201131532_index_foreign_keys_in_notes.rb"))
45
- IndexForeignKeysInNotes.migrate(:up)
46
-
47
- ::Object.send(:remove_const, :IndexForeignKeysInUsers)
48
- ::Object.send(:remove_const, :IndexForeignKeysInOrganizations)
49
- ::Object.send(:remove_const, :IndexForeignKeysInNotes)
50
- end
51
- end
52
-
53
- assert_indexes([
54
- ["notes", ["notable_type", "notable_id"]],
55
- ["users", ["organization_id"]],
56
- ["users", ["account_id"]],
57
- ["organizations", ["owner_id"]]
58
- ])
59
-
60
- assert_equal(5, Dir.entries("./db/migrate").size)
61
- end
62
- end
63
-
64
- def test_create_migrations_raises_when_malformed_inpout
65
- Tempfile.create do |file|
66
- file.write(<<~INDEXES)
67
- add an index on users() - foreign keys are often used in database lookups and should be indexed for performance reasons
68
- INDEXES
69
- file.flush
70
-
71
- assert_raises(RuntimeError) do
72
- capture_io do
73
- ActiveRecordDoctor::AddIndexesGenerator.start([file.path])
74
- end
75
- end
76
- end
77
- end
78
-
79
- def test_create_migrations_skips_blank_lines
80
- Dir.mktmpdir do |dir|
81
- Dir.chdir(dir)
82
-
83
- path = File.join(dir, "indexes.txt")
84
- File.write(path, "\n")
85
-
86
- capture_io do
87
- Time.stub(:now, TIMESTAMP) do
88
- # Not raising an exception is considered success.
89
- ActiveRecordDoctor::AddIndexesGenerator.start([path])
90
- end
91
- end
92
- end
93
- end
94
-
95
- def test_create_migrations_truncates_long_index_names
96
- # Both the table and column names must be quite long. Otherwise, the
97
- # we might reach table or column name length limits and fail to generate an
98
- # index name that's long enough.
99
- create_table(:organizations_migrated_from_legacy_app) do |t|
100
- t.integer :legacy_owner_id_compatible_with_v1_to_v8
101
- end
102
-
103
- Dir.mktmpdir do |dir|
104
- Dir.chdir(dir)
105
-
106
- path = File.join(dir, "indexes.txt")
107
- File.write(path, <<~INDEXES)
108
- add an index on organizations_migrated_from_legacy_app(legacy_owner_id_compatible_with_v1_to_v8) - foreign keys are often used in database lookups and should be indexed for performance reasons
109
- INDEXES
110
-
111
- capture_io do
112
- Time.stub(:now, TIMESTAMP) do
113
- # If no exceptions are raised then we consider this to be a success.
114
- ActiveRecordDoctor::AddIndexesGenerator.start([path])
115
-
116
- load(File.join(
117
- "db",
118
- "migrate",
119
- "20210201131530_index_foreign_keys_in_organizations_migrated_from_legacy_app.rb"
120
- ))
121
- ::IndexForeignKeysInOrganizationsMigratedFromLegacyApp.migrate(:up)
122
-
123
- ::Object.send(:remove_const, :IndexForeignKeysInOrganizationsMigratedFromLegacyApp)
124
- end
125
- end
126
- end
127
- end
128
-
129
- private
130
-
131
- def assert_indexes(expected_indexes)
132
- actual_indexes =
133
- ActiveRecord::Base.connection.tables.map do |table|
134
- ActiveRecord::Base.connection.indexes(table).map do |index|
135
- [index.table, index.columns]
136
- end
137
- end.flatten(1)
138
-
139
- assert_equal(expected_indexes.sort, actual_indexes.sort)
140
- end
141
- end
data/test/setup.rb DELETED
@@ -1,124 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Configure Active Record.
4
-
5
- # We must import "uri" explicitly as otherwsie URI won't be accessible in
6
- # Ruby 2.7.2 / Rails 6.
7
- require "uri"
8
-
9
- require "active_record"
10
- require "pg"
11
- require "mysql2"
12
-
13
- adapter = ENV.fetch("DATABASE_ADAPTER")
14
- ActiveRecord::Base.establish_connection(
15
- adapter: adapter,
16
- host: ENV["DATABASE_HOST"],
17
- port: ENV["DATABASE_PORT"],
18
- username: ENV["DATABASE_USERNAME"],
19
- password: ENV["DATABASE_PASSWORD"],
20
- database: "active_record_doctor_test"
21
- )
22
-
23
- puts "Using #{adapter}"
24
-
25
- # We need to call #connection to enfore Active Record to actually establish
26
- # the connection.
27
- ActiveRecord::Base.connection
28
-
29
- # Load Active Record Doctor.
30
- require "active_record_doctor"
31
-
32
- # Configure the test suite.
33
- require "minitest"
34
- require "minitest/autorun"
35
- require "minitest/fork_executor"
36
- require "transient_record"
37
-
38
- # Filter out Minitest backtrace while allowing backtrace from other libraries
39
- # to be shown.
40
- Minitest.backtrace_filter = Minitest::BacktraceFilter.new
41
-
42
- # Uncomment in case there's test case interference.
43
- Minitest.parallel_executor = Minitest::ForkExecutor.new
44
-
45
- # Prepare the test class.
46
- class Minitest::Test
47
- include TransientRecord
48
-
49
- def setup
50
- # Delete remnants (models and tables) of previous test case runs.
51
- TransientRecord.cleanup
52
- end
53
-
54
- def teardown
55
- @config_path = nil
56
-
57
- if @previous_dir
58
- Dir.chdir(@previous_dir)
59
- @previous_dir = nil
60
- end
61
-
62
- # Ensure all remnants of previous test runs, most likely in form of tables,
63
- # are removed.
64
- TransientRecord.cleanup
65
- end
66
-
67
- private
68
-
69
- attr_reader :config_path
70
-
71
- def config_file(content)
72
- @previous_dir = Dir.pwd
73
-
74
- directory = Dir.mktmpdir("active_record_doctor")
75
- @config_path = File.join(directory, ".active_record_doctor")
76
- File.write(@config_path, content)
77
- Dir.chdir(directory)
78
-
79
- @config_path
80
- end
81
-
82
- def postgresql?
83
- ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
84
- end
85
-
86
- def mysql?
87
- ActiveRecord::Base.connection.adapter_name == "Mysql2"
88
- end
89
-
90
- def detector_name
91
- self.class.name.sub(/Test$/, "").demodulize.underscore.to_sym
92
- end
93
-
94
- def run_detector
95
- io = StringIO.new
96
- runner = ActiveRecordDoctor::Runner.new(
97
- config: load_config,
98
- logger: ActiveRecordDoctor::Logger::Dummy.new,
99
- io: io
100
- )
101
- success = runner.run_one(detector_name)
102
- [success, io.string]
103
- end
104
-
105
- def load_config
106
- ActiveRecordDoctor.load_config_with_defaults(@config_path)
107
- end
108
-
109
- def assert_problems(expected_output)
110
- success, output = run_detector
111
- assert_equal(sort_lines(expected_output), sort_lines(output))
112
- refute(success, "Expected the detector to return failure.")
113
- end
114
-
115
- def refute_problems(expected_output = "")
116
- success, output = run_detector
117
- assert_equal(sort_lines(expected_output), sort_lines(output))
118
- assert(success, "Expected the detector to return success.")
119
- end
120
-
121
- def sort_lines(string)
122
- string.split("\n").sort
123
- end
124
- end