active_record_doctor 1.12.0 → 1.14.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +29 -2
  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/rake/task.rb +35 -3
  14. data/lib/active_record_doctor/runner.rb +1 -1
  15. data/lib/active_record_doctor/utils.rb +2 -2
  16. data/lib/active_record_doctor/version.rb +1 -1
  17. data/lib/tasks/active_record_doctor.rake +1 -2
  18. metadata +12 -49
  19. data/test/active_record_doctor/config/loader_test.rb +0 -120
  20. data/test/active_record_doctor/config_test.rb +0 -116
  21. data/test/active_record_doctor/detectors/disable_test.rb +0 -30
  22. data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +0 -277
  23. data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +0 -79
  24. data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +0 -511
  25. data/test/active_record_doctor/detectors/incorrect_length_validation_test.rb +0 -107
  26. data/test/active_record_doctor/detectors/mismatched_foreign_key_type_test.rb +0 -116
  27. data/test/active_record_doctor/detectors/missing_foreign_keys_test.rb +0 -70
  28. data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +0 -273
  29. data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +0 -232
  30. data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +0 -496
  31. data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +0 -77
  32. data/test/active_record_doctor/detectors/undefined_table_references_test.rb +0 -55
  33. data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +0 -177
  34. data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +0 -116
  35. data/test/active_record_doctor/runner_test.rb +0 -41
  36. data/test/generators/active_record_doctor/add_indexes/add_indexes_generator_test.rb +0 -141
  37. 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