active_record_doctor 1.10.0 → 1.12.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -15
  3. data/lib/active_record_doctor/detectors/base.rb +194 -53
  4. data/lib/active_record_doctor/detectors/extraneous_indexes.rb +36 -34
  5. data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +2 -5
  6. data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +87 -37
  7. data/lib/active_record_doctor/detectors/incorrect_length_validation.rb +7 -10
  8. data/lib/active_record_doctor/detectors/mismatched_foreign_key_type.rb +16 -9
  9. data/lib/active_record_doctor/detectors/missing_foreign_keys.rb +2 -4
  10. data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +13 -11
  11. data/lib/active_record_doctor/detectors/missing_presence_validation.rb +14 -7
  12. data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +70 -35
  13. data/lib/active_record_doctor/detectors/short_primary_key_type.rb +4 -4
  14. data/lib/active_record_doctor/detectors/undefined_table_references.rb +2 -2
  15. data/lib/active_record_doctor/detectors/unindexed_deleted_at.rb +5 -13
  16. data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +35 -11
  17. data/lib/active_record_doctor/logger/dummy.rb +11 -0
  18. data/lib/active_record_doctor/logger/hierarchical.rb +22 -0
  19. data/lib/active_record_doctor/logger.rb +6 -0
  20. data/lib/active_record_doctor/rake/task.rb +10 -1
  21. data/lib/active_record_doctor/runner.rb +8 -3
  22. data/lib/active_record_doctor/utils.rb +21 -0
  23. data/lib/active_record_doctor/version.rb +1 -1
  24. data/lib/active_record_doctor.rb +5 -0
  25. data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +14 -14
  26. data/test/active_record_doctor/detectors/disable_test.rb +1 -1
  27. data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +59 -6
  28. data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +7 -7
  29. data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +175 -57
  30. data/test/active_record_doctor/detectors/incorrect_length_validation_test.rb +16 -14
  31. data/test/active_record_doctor/detectors/mismatched_foreign_key_type_test.rb +35 -1
  32. data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +46 -23
  33. data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +55 -27
  34. data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +216 -47
  35. data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +5 -0
  36. data/test/active_record_doctor/detectors/undefined_table_references_test.rb +11 -13
  37. data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +39 -1
  38. data/test/active_record_doctor/runner_test.rb +18 -19
  39. data/test/generators/active_record_doctor/add_indexes/add_indexes_generator_test.rb +16 -6
  40. data/test/setup.rb +10 -6
  41. metadata +23 -7
  42. data/test/model_factory.rb +0 -128
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDoctor
4
+ module Logger
5
+ class Dummy # :nodoc:
6
+ def log(_message)
7
+ yield if block_given?
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDoctor
4
+ module Logger
5
+ class Hierarchical # :nodoc:
6
+ def initialize(io)
7
+ @io = io
8
+ @nesting = 0
9
+ end
10
+
11
+ def log(message)
12
+ @io.puts(" " * @nesting + message.to_s)
13
+ return if !block_given?
14
+
15
+ @nesting += 1
16
+ result = yield
17
+ @nesting -= 1
18
+ result
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDoctor
4
+ module Logger # :nodoc:
5
+ end
6
+ end
@@ -64,7 +64,7 @@ module ActiveRecordDoctor
64
64
  private
65
65
 
66
66
  def runner
67
- @runner ||= ActiveRecordDoctor::Runner.new(config)
67
+ @runner ||= ActiveRecordDoctor::Runner.new(config: config, logger: logger)
68
68
  end
69
69
 
70
70
  def config
@@ -73,6 +73,15 @@ module ActiveRecordDoctor
73
73
  ActiveRecordDoctor.load_config_with_defaults(path)
74
74
  end
75
75
  end
76
+
77
+ def logger
78
+ @logger ||=
79
+ if ENV.include?("ACTIVE_RECORD_DOCTOR_DEBUG")
80
+ ActiveRecordDoctor::Logger::Hierarchical.new($stderr)
81
+ else
82
+ ActiveRecordDoctor::Logger::Dummy.new
83
+ end
84
+ end
76
85
  end
77
86
  end
78
87
  end
@@ -5,14 +5,19 @@ module ActiveRecordDoctor # :nodoc:
5
5
  # and an output device for use by detectors.
6
6
  class Runner
7
7
  # io is injected via constructor parameters to facilitate testing.
8
- def initialize(config, io = $stdout)
8
+ def initialize(config:, logger:, io: $stdout)
9
9
  @config = config
10
+ @logger = logger
10
11
  @io = io
11
12
  end
12
13
 
13
14
  def run_one(name)
14
15
  ActiveRecordDoctor.handle_exception do
15
- ActiveRecordDoctor.detectors.fetch(name).run(config, io)
16
+ ActiveRecordDoctor.detectors.fetch(name).run(
17
+ config: config,
18
+ logger: logger,
19
+ io: io
20
+ )
16
21
  end
17
22
  end
18
23
 
@@ -36,6 +41,6 @@ module ActiveRecordDoctor # :nodoc:
36
41
 
37
42
  private
38
43
 
39
- attr_reader :config, :io
44
+ attr_reader :config, :logger, :io
40
45
  end
41
46
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDoctor
4
+ module Utils # :nodoc:
5
+ class << self
6
+ def postgresql?(connection = ActiveRecord::Base.connection)
7
+ ["PostgreSQL", "PostGIS"].include?(connection.adapter_name)
8
+ end
9
+
10
+ def mysql?(connection = ActiveRecord::Base.connection)
11
+ connection.adapter_name == "Mysql2"
12
+ end
13
+
14
+ def expression_indexes_unsupported?(connection = ActiveRecord::Base.connection)
15
+ (ActiveRecord::VERSION::STRING < "5.0") ||
16
+ # Active Record < 6 is unable to correctly parse expression indexes for MySQL.
17
+ (mysql?(connection) && ActiveRecord::VERSION::STRING < "6.0")
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordDoctor
4
- VERSION = "1.10.0"
4
+ VERSION = "1.12.0"
5
5
  end
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_record_doctor/railtie" if defined?(Rails) && defined?(Rails::Railtie)
4
+ require "active_record_doctor/utils"
5
+ require "active_record_doctor/logger"
6
+ require "active_record_doctor/logger/dummy"
7
+ require "active_record_doctor/logger/hierarchical"
4
8
  require "active_record_doctor/detectors"
5
9
  require "active_record_doctor/detectors/base"
6
10
  require "active_record_doctor/detectors/missing_presence_validation"
@@ -22,6 +26,7 @@ require "active_record_doctor/runner"
22
26
  require "active_record_doctor/version"
23
27
  require "active_record_doctor/config"
24
28
  require "active_record_doctor/config/loader"
29
+ require "active_record_doctor/rake/task"
25
30
 
26
31
  module ActiveRecordDoctor # :nodoc:
27
32
  end
@@ -10,16 +10,16 @@ module ActiveRecordDoctor
10
10
  migration_descriptions = read_migration_descriptions(path)
11
11
  now = Time.now
12
12
 
13
- migration_descriptions.each_with_index do |(table, columns), index|
13
+ migration_descriptions.each_with_index do |(table, indexes), index|
14
14
  timestamp = (now + index).strftime("%Y%m%d%H%M%S")
15
15
  file_name = "db/migrate/#{timestamp}_index_foreign_keys_in_#{table}.rb"
16
- create_file(file_name, content(table, columns).tap { |x| puts x })
16
+ create_file(file_name, content(table, indexes).tap { |x| puts x })
17
17
  end
18
18
  end
19
19
 
20
20
  private
21
21
 
22
- INPUT_LINE = /^add an index on (\w+)\.(\w+) - .*$/.freeze
22
+ INPUT_LINE = /^add an index on (\w+)\((.+)\) - .*$/.freeze
23
23
  private_constant :INPUT_LINE
24
24
 
25
25
  def read_migration_descriptions(path)
@@ -34,41 +34,41 @@ module ActiveRecordDoctor
34
34
  end
35
35
 
36
36
  table = match[1]
37
- column = match[2]
37
+ columns = match[2].split(",").map(&:strip)
38
38
 
39
- tables_to_columns[table] << column
39
+ tables_to_columns[table] << columns
40
40
  end
41
41
 
42
42
  tables_to_columns
43
43
  end
44
44
 
45
- def content(table, columns)
45
+ def content(table, indexes)
46
46
  # In order to properly indent the resulting code, we must disable the
47
47
  # rubocop rule below.
48
48
 
49
49
  <<MIGRATION
50
50
  class IndexForeignKeysIn#{table.camelize} < ActiveRecord::Migration#{migration_version}
51
51
  def change
52
- #{add_indexes(table, columns)}
52
+ #{add_indexes(table, indexes)}
53
53
  end
54
54
  end
55
55
  MIGRATION
56
56
  end
57
57
 
58
- def add_indexes(table, columns)
59
- columns.map do |column|
60
- add_index(table, column)
58
+ def add_indexes(table, indexes)
59
+ indexes.map do |columns|
60
+ add_index(table, columns)
61
61
  end.join("\n")
62
62
  end
63
63
 
64
- def add_index(table, column)
64
+ def add_index(table, columns)
65
65
  connection = ActiveRecord::Base.connection
66
66
 
67
- index_name = connection.index_name(table, column)
67
+ index_name = connection.index_name(table, columns)
68
68
  if index_name.size > connection.index_name_length
69
- " add_index :#{table}, :#{column}, name: '#{index_name.first(connection.index_name_length)}'"
69
+ " add_index :#{table}, #{columns.inspect}, name: '#{index_name.first(connection.index_name_length)}'"
70
70
  else
71
- " add_index :#{table}, :#{column}"
71
+ " add_index :#{table}, #{columns.inspect}"
72
72
  end
73
73
  end
74
74
 
@@ -6,7 +6,7 @@ class ActiveRecordDoctor::Detectors::DisableTest < Minitest::Test
6
6
  def test_disabling
7
7
  create_table(:users) do |t|
8
8
  t.string :name, null: true
9
- end.create_model do
9
+ end.define_model do
10
10
  validates :name, presence: true
11
11
  end
12
12
 
@@ -7,7 +7,7 @@ class ActiveRecordDoctor::Detectors::ExtraneousIndexesTest < Minitest::Test
7
7
  end
8
8
 
9
9
  assert_problems(<<OUTPUT)
10
- remove index_users_on_id - coincides with the primary key on the table
10
+ remove index_users_on_id from users - coincides with the primary key on the table
11
11
  OUTPUT
12
12
  end
13
13
 
@@ -28,7 +28,7 @@ OUTPUT
28
28
  end
29
29
 
30
30
  assert_problems(<<OUTPUT)
31
- remove index_profiles_on_user_id - coincides with the primary key on the table
31
+ remove index_profiles_on_user_id from profiles - coincides with the primary key on the table
32
32
  OUTPUT
33
33
  end
34
34
 
@@ -42,7 +42,7 @@ OUTPUT
42
42
  ActiveRecord::Base.connection.add_index :users, :email, name: "index_users_on_email"
43
43
 
44
44
  assert_problems(<<OUTPUT)
45
- remove index_users_on_email - can be replaced by unique_index_on_users_email
45
+ remove the index index_users_on_email from the table users - queries should be able to use the following index instead: unique_index_on_users_email
46
46
  OUTPUT
47
47
  end
48
48
 
@@ -59,7 +59,7 @@ OUTPUT
59
59
  end
60
60
 
61
61
  assert_problems(<<OUTPUT)
62
- remove index_users_on_last_name - can be replaced by index_users_on_last_name_and_first_name_and_email or unique_index_on_users_last_name_and_first_name
62
+ remove the index index_users_on_last_name from the table users - queries should be able to use the following indices instead: index_users_on_last_name_and_first_name_and_email or unique_index_on_users_last_name_and_first_name
63
63
  OUTPUT
64
64
  end
65
65
 
@@ -78,7 +78,7 @@ OUTPUT
78
78
  ActiveRecord::Base.connection.add_index :users, [:last_name, :first_name]
79
79
 
80
80
  assert_problems(<<OUTPUT)
81
- remove index_users_on_last_name_and_first_name - can be replaced by index_users_on_last_name_and_first_name_and_email or unique_index_on_users_last_name_and_first_name
81
+ remove the index index_users_on_last_name_and_first_name from the table users - queries should be able to use the following indices instead: index_users_on_last_name_and_first_name_and_email or unique_index_on_users_last_name_and_first_name
82
82
  OUTPUT
83
83
  end
84
84
 
@@ -91,10 +91,36 @@ OUTPUT
91
91
  end
92
92
 
93
93
  assert_problems(<<OUTPUT)
94
- remove index_users_on_last_name_and_first_name - can be replaced by index_users_on_first_name
94
+ remove the index index_users_on_last_name_and_first_name from the table users - queries should be able to use the following index instead: index_users_on_first_name
95
95
  OUTPUT
96
96
  end
97
97
 
98
+ def test_expression_index_not_covered_by_multicolumn_index
99
+ skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
100
+
101
+ create_table(:users) do |t|
102
+ t.string :first_name
103
+ t.string :email
104
+ t.index "(lower(email))"
105
+ t.index [:first_name, :email]
106
+ end
107
+
108
+ refute_problems
109
+ end
110
+
111
+ def test_unique_expression_index_not_covered_by_unique_multicolumn_index
112
+ skip("Expression indexes are not supported") if ActiveRecordDoctor::Utils.expression_indexes_unsupported?
113
+
114
+ create_table(:users) do |t|
115
+ t.string :first_name
116
+ t.string :email
117
+ t.index "(lower(email))", unique: true
118
+ t.index [:first_name, :email], unique: true
119
+ end
120
+
121
+ refute_problems
122
+ end
123
+
98
124
  def test_not_covered_by_different_index_type
99
125
  create_table(:users) do |t|
100
126
  t.string :first_name
@@ -139,6 +165,33 @@ OUTPUT
139
165
  refute_problems
140
166
  end
141
167
 
168
+ def test_single_column_covered_by_multi_column_on_materialized_view_is_duplicate
169
+ skip("Only PostgreSQL supports materialized views") unless postgresql?
170
+
171
+ begin
172
+ create_table(:users) do |t|
173
+ t.string :first_name
174
+ t.string :last_name
175
+ t.integer :age
176
+ end
177
+
178
+ connection = ActiveRecord::Base.connection
179
+ connection.execute(<<-SQL)
180
+ CREATE MATERIALIZED VIEW user_initials AS
181
+ SELECT first_name, last_name FROM users
182
+ SQL
183
+
184
+ connection.add_index(:user_initials, [:last_name, :first_name])
185
+ connection.add_index(:user_initials, :last_name)
186
+
187
+ assert_problems(<<OUTPUT)
188
+ remove the index index_user_initials_on_last_name from the table user_initials - queries should be able to use the following index instead: index_user_initials_on_last_name_and_first_name
189
+ OUTPUT
190
+ ensure
191
+ connection.execute("DROP MATERIALIZED VIEW user_initials")
192
+ end
193
+ end
194
+
142
195
  def test_config_ignore_tables
143
196
  # The detector recognizes two kinds of errors and both must take
144
197
  # ignore_tables into account. We trigger those errors by indexing the
@@ -5,7 +5,7 @@ class ActiveRecordDoctor::Detectors::IncorrectBooleanPresenceValidationTest < Mi
5
5
  create_table(:users) do |t|
6
6
  t.string :email, null: false
7
7
  t.boolean :active, null: false
8
- end.create_model do
8
+ end.define_model do
9
9
  # email is a non-boolean column whose presence CAN be validated in the
10
10
  # usual way. We include it in the test model to ensure the detector reports
11
11
  # only boolean columns.
@@ -13,14 +13,14 @@ class ActiveRecordDoctor::Detectors::IncorrectBooleanPresenceValidationTest < Mi
13
13
  end
14
14
 
15
15
  assert_problems(<<~OUTPUT)
16
- replace the `presence` validator on ModelFactory::Models::User.active with `inclusion` - `presence` can't be used on booleans
16
+ replace the `presence` validator on TransientRecord::Models::User.active with `inclusion` - `presence` can't be used on booleans
17
17
  OUTPUT
18
18
  end
19
19
 
20
20
  def test_inclusion_is_not_reported
21
21
  create_table(:users) do |t|
22
22
  t.boolean :active, null: false
23
- end.create_model do
23
+ end.define_model do
24
24
  validates :active, inclusion: { in: [true, false] }
25
25
  end
26
26
 
@@ -28,7 +28,7 @@ class ActiveRecordDoctor::Detectors::IncorrectBooleanPresenceValidationTest < Mi
28
28
  end
29
29
 
30
30
  def test_models_with_non_existent_tables_are_skipped
31
- create_model(:User)
31
+ define_model(:User)
32
32
 
33
33
  refute_problems
34
34
  end
@@ -36,7 +36,7 @@ class ActiveRecordDoctor::Detectors::IncorrectBooleanPresenceValidationTest < Mi
36
36
  def test_config_ignore_models
37
37
  create_table(:users) do |t|
38
38
  t.string :email, null: false
39
- end.create_model
39
+ end.define_model
40
40
 
41
41
  config_file(<<-CONFIG)
42
42
  ActiveRecordDoctor.configure do |config|
@@ -51,7 +51,7 @@ class ActiveRecordDoctor::Detectors::IncorrectBooleanPresenceValidationTest < Mi
51
51
  def test_global_ignore_models
52
52
  create_table(:users) do |t|
53
53
  t.string :email, null: false
54
- end.create_model
54
+ end.define_model
55
55
 
56
56
  config_file(<<-CONFIG)
57
57
  ActiveRecordDoctor.configure do |config|
@@ -65,7 +65,7 @@ class ActiveRecordDoctor::Detectors::IncorrectBooleanPresenceValidationTest < Mi
65
65
  def test_config_ignore_attributes
66
66
  create_table(:users) do |t|
67
67
  t.string :email, null: false
68
- end.create_model
68
+ end.define_model
69
69
 
70
70
  config_file(<<-CONFIG)
71
71
  ActiveRecordDoctor.configure do |config|