active_record_doctor 1.8.0 → 1.10.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +316 -54
  3. data/lib/active_record_doctor/config/default.rb +76 -0
  4. data/lib/active_record_doctor/config/loader.rb +137 -0
  5. data/lib/active_record_doctor/config.rb +14 -0
  6. data/lib/active_record_doctor/detectors/base.rb +142 -21
  7. data/lib/active_record_doctor/detectors/extraneous_indexes.rb +59 -48
  8. data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +31 -23
  9. data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +102 -35
  10. data/lib/active_record_doctor/detectors/incorrect_length_validation.rb +63 -0
  11. data/lib/active_record_doctor/detectors/mismatched_foreign_key_type.rb +45 -0
  12. data/lib/active_record_doctor/detectors/missing_foreign_keys.rb +32 -23
  13. data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +41 -28
  14. data/lib/active_record_doctor/detectors/missing_presence_validation.rb +29 -23
  15. data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +92 -32
  16. data/lib/active_record_doctor/detectors/short_primary_key_type.rb +45 -0
  17. data/lib/active_record_doctor/detectors/undefined_table_references.rb +17 -20
  18. data/lib/active_record_doctor/detectors/unindexed_deleted_at.rb +43 -18
  19. data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +31 -20
  20. data/lib/active_record_doctor/detectors.rb +12 -4
  21. data/lib/active_record_doctor/errors.rb +226 -0
  22. data/lib/active_record_doctor/help.rb +39 -0
  23. data/lib/active_record_doctor/rake/task.rb +78 -0
  24. data/lib/active_record_doctor/runner.rb +41 -0
  25. data/lib/active_record_doctor/version.rb +1 -1
  26. data/lib/active_record_doctor.rb +8 -3
  27. data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +34 -21
  28. data/lib/tasks/active_record_doctor.rake +9 -18
  29. data/test/active_record_doctor/config/loader_test.rb +120 -0
  30. data/test/active_record_doctor/config_test.rb +116 -0
  31. data/test/active_record_doctor/detectors/disable_test.rb +30 -0
  32. data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +165 -8
  33. data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +48 -5
  34. data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +288 -12
  35. data/test/active_record_doctor/detectors/incorrect_length_validation_test.rb +105 -0
  36. data/test/active_record_doctor/detectors/mismatched_foreign_key_type_test.rb +82 -0
  37. data/test/active_record_doctor/detectors/missing_foreign_keys_test.rb +50 -4
  38. data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +172 -24
  39. data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +111 -14
  40. data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +223 -10
  41. data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +72 -0
  42. data/test/active_record_doctor/detectors/undefined_table_references_test.rb +34 -21
  43. data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +118 -8
  44. data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +56 -4
  45. data/test/active_record_doctor/runner_test.rb +42 -0
  46. data/test/generators/active_record_doctor/add_indexes/add_indexes_generator_test.rb +131 -0
  47. data/test/model_factory.rb +73 -23
  48. data/test/setup.rb +65 -71
  49. metadata +43 -7
  50. data/lib/active_record_doctor/printers/io_printer.rb +0 -133
  51. data/lib/active_record_doctor/task.rb +0 -28
  52. data/test/active_record_doctor/printers/io_printer_test.rb +0 -33
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDoctor # :nodoc:
4
+ class << self
5
+ # The config file that's currently being processed by .load_config.
6
+ attr_reader :current_config
7
+
8
+ # This method is part of the public API that is intended for use by
9
+ # active_record_doctor users. The remaining methods are considered to be
10
+ # public-not-published.
11
+ def configure(&block)
12
+ # If current_config is set it means that .configure was already called
13
+ # so we must raise an error.
14
+ raise ActiveRecordDoctor::Error::ConfigureCalledTwice if current_config
15
+
16
+ # Determine the recognized global and detector settings based on detector
17
+ # metadata. recognizedd_detectors maps detector names to setting names.
18
+ # recognized_globals contains global setting names.
19
+ recognized_detectors = {}
20
+ recognized_globals = []
21
+
22
+ ActiveRecordDoctor.detectors.each do |name, detector|
23
+ locals, globals = detector.locals_and_globals
24
+
25
+ recognized_detectors[name] = locals
26
+ recognized_globals.concat(globals)
27
+ end
28
+
29
+ # The same global can be used by multiple detectors so we must remove
30
+ # duplicates to ensure they aren't reported mutliple times via the user
31
+ # interface (e.g. in error messages).
32
+ recognized_globals.uniq!
33
+
34
+ # Prepare an empty configuration and call the loader. After .new returns
35
+ # @current_config will contain the configuration provided by the block.
36
+ @current_config = Config.new({}, {})
37
+ Loader.new(current_config, recognized_globals, recognized_detectors, &block)
38
+
39
+ # This method is part of the public API expected to be called by users.
40
+ # In order to avoid leaking internal objects, we return an explicit nil.
41
+ nil
42
+ end
43
+
44
+ def load_config(path)
45
+ begin
46
+ load(path)
47
+ rescue ActiveRecordDoctor::Error
48
+ raise
49
+ rescue LoadError
50
+ raise ActiveRecordDoctor::Error::ConfigurationFileMissing
51
+ rescue StandardError => e
52
+ raise ActiveRecordDoctor::Error::ConfigurationError[e]
53
+ end
54
+ raise ActiveRecordDoctor::Error::ConfigureNotCalled if current_config.nil?
55
+
56
+ # Store the configuration and reset @current_config. We cannot reset
57
+ # @current_config in .configure because that would prevent us from
58
+ # detecting multiple calls to that method.
59
+ config = @current_config
60
+ @current_config = nil
61
+
62
+ config
63
+ rescue ActiveRecordDoctor::Error => e
64
+ e.config_path = path
65
+ raise e
66
+ end
67
+
68
+ DEFAULT_CONFIG_PATH = File.join(__dir__, "default.rb").freeze
69
+ private_constant :DEFAULT_CONFIG_PATH
70
+
71
+ def load_config_with_defaults(path)
72
+ default_config = load_config(DEFAULT_CONFIG_PATH)
73
+ return default_config if path.nil?
74
+
75
+ config = load_config(path)
76
+ default_config.merge(config)
77
+ end
78
+ end
79
+
80
+ # A class used for loading user-provided configuration files.
81
+ class Loader
82
+ def initialize(config, recognized_globals, recognized_detectors, &block)
83
+ @config = config
84
+ @recognized_globals = recognized_globals
85
+ @recognized_detectors = recognized_detectors
86
+ instance_eval(&block)
87
+ end
88
+
89
+ def global(name, value)
90
+ name = name.to_sym
91
+
92
+ unless recognized_globals.include?(name)
93
+ raise ActiveRecordDoctor::Error::UnrecognizedGlobalSetting[
94
+ name,
95
+ recognized_globals
96
+ ]
97
+ end
98
+
99
+ if config.globals.include?(name)
100
+ raise ActiveRecordDoctor::Error::DuplicateGlobalSetting[name]
101
+ end
102
+
103
+ config.globals[name] = value
104
+ end
105
+
106
+ def detector(name, settings)
107
+ name = name.to_sym
108
+
109
+ recognized_settings = recognized_detectors[name]
110
+ if recognized_settings.nil?
111
+ raise ActiveRecordDoctor::Error::UnrecognizedDetectorName[
112
+ name,
113
+ recognized_detectors.keys
114
+ ]
115
+ end
116
+
117
+ if config.detectors.include?(name)
118
+ raise ActiveRecordDoctor::Error::DetectorConfiguredTwice[name]
119
+ end
120
+
121
+ unrecognized_settings = settings.keys - recognized_settings
122
+ unless unrecognized_settings.empty?
123
+ raise ActiveRecordDoctor::Error::UnrecognizedDetectorSettings[
124
+ name,
125
+ unrecognized_settings,
126
+ recognized_settings
127
+ ]
128
+ end
129
+
130
+ config.detectors[name] = settings
131
+ end
132
+
133
+ private
134
+
135
+ attr_reader :config, :recognized_globals, :recognized_detectors
136
+ end
137
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDoctor # :nodoc:
4
+ Config = Struct.new(:globals, :detectors) do
5
+ def merge(config)
6
+ globals = self.globals.merge(config.globals)
7
+ detectors = self.detectors.merge(config.detectors) do |_name, self_settings, config_settings|
8
+ self_settings.merge(config_settings)
9
+ end
10
+
11
+ Config.new(globals, detectors)
12
+ end
13
+ end
14
+ end
@@ -4,60 +4,181 @@ module ActiveRecordDoctor
4
4
  module Detectors
5
5
  # Base class for all active_record_doctor detectors.
6
6
  class Base
7
+ BASE_CONFIG = {
8
+ enabled: {
9
+ description: "set to false to disable the detector altogether"
10
+ }
11
+ }.freeze
12
+
7
13
  class << self
8
14
  attr_reader :description
9
15
 
10
- def run
11
- new.run
16
+ def run(config, io)
17
+ new(config, io).run
18
+ end
19
+
20
+ def underscored_name
21
+ name.demodulize.underscore.to_sym
22
+ end
23
+
24
+ def config
25
+ @config.merge(BASE_CONFIG)
26
+ end
27
+
28
+ def locals_and_globals
29
+ locals = []
30
+ globals = []
31
+
32
+ config.each do |key, metadata|
33
+ locals << key
34
+ globals << key if metadata[:global]
35
+ end
36
+
37
+ [locals, globals]
38
+ end
39
+ end
40
+
41
+ def initialize(config, io)
42
+ @problems = []
43
+ @config = config
44
+ @io = io
45
+ end
46
+
47
+ def run
48
+ @problems = []
49
+
50
+ detect if config(:enabled)
51
+ @problems.each do |problem|
52
+ @io.puts(message(**problem))
12
53
  end
54
+
55
+ success = @problems.empty?
56
+ @problems = nil
57
+ success
13
58
  end
14
59
 
15
60
  private
16
61
 
17
- def problems(problems, options = {})
18
- [problems, options]
62
+ def config(key)
63
+ local = @config.detectors.fetch(underscored_name).fetch(key)
64
+ return local if !self.class.config.fetch(key)[:global]
65
+
66
+ global = @config.globals[key]
67
+ return local if global.nil?
68
+
69
+ # Right now, all globals are arrays so we can merge them here. Once
70
+ # we add non-array globals we'll need to support per-global merging.
71
+ Array.new(local).concat(global)
72
+ end
73
+
74
+ def detect
75
+ raise("#detect should be implemented by a subclass")
76
+ end
77
+
78
+ def message(**_attrs)
79
+ raise("#message should be implemented by a subclass")
80
+ end
81
+
82
+ def problem!(**attrs)
83
+ @problems << attrs
84
+ end
85
+
86
+ def warning(message)
87
+ puts(message)
19
88
  end
20
89
 
21
90
  def connection
22
91
  @connection ||= ActiveRecord::Base.connection
23
92
  end
24
93
 
25
- def indexes(table_name)
26
- connection.indexes(table_name)
94
+ def indexes(table_name, except: [])
95
+ connection.indexes(table_name).reject do |index|
96
+ except.include?(index.name)
97
+ end
98
+ end
99
+
100
+ def tables(except: [])
101
+ tables =
102
+ if ActiveRecord::VERSION::STRING >= "5.1"
103
+ connection.tables
104
+ else
105
+ connection.data_sources
106
+ end
107
+
108
+ tables.reject do |table|
109
+ except.include?(table)
110
+ end
27
111
  end
28
112
 
29
- def tables
30
- connection.tables
113
+ def primary_key(table_name)
114
+ primary_key_name = connection.primary_key(table_name)
115
+ return nil if primary_key_name.nil?
116
+
117
+ column(table_name, primary_key_name)
31
118
  end
32
119
 
33
- def table_exists?(table_name)
34
- connection.table_exists?(table_name)
120
+ def column(table_name, column_name)
121
+ connection.columns(table_name).find { |column| column.name == column_name }
35
122
  end
36
123
 
37
124
  def views
38
125
  @views ||=
39
126
  if connection.respond_to?(:views)
40
127
  connection.views
41
- elsif connection.adapter_name == "PostgreSQL"
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')
128
+ elsif postgresql?
129
+ ActiveRecord::Base.connection.select_values(<<-SQL)
130
+ SELECT relname FROM pg_class WHERE relkind IN ('m', 'v')
131
+ SQL
132
+ elsif connection.adapter_name == "Mysql2"
133
+ ActiveRecord::Base.connection.select_values(<<-SQL)
134
+ SHOW FULL TABLES WHERE table_type = 'VIEW'
44
135
  SQL
45
- else # rubocop:disable Style/EmptyElse
136
+ else
46
137
  # We don't support this Rails/database combination yet.
47
- nil
138
+ []
48
139
  end
49
140
  end
50
141
 
51
- def hash_from_pairs(pairs)
52
- Hash[*pairs.flatten(1)]
142
+ def not_null_check_constraint_exists?(table, column)
143
+ check_constraints(table).any? do |definition|
144
+ definition =~ /\A#{column.name} IS NOT NULL\z/i ||
145
+ definition =~ /\A#{connection.quote_column_name(column.name)} IS NOT NULL\z/i
146
+ end
147
+ end
148
+
149
+ def check_constraints(table_name)
150
+ # ActiveRecord 6.1+
151
+ if connection.respond_to?(:supports_check_constraints?) && connection.supports_check_constraints?
152
+ connection.check_constraints(table_name).select(&:validated?).map(&:expression)
153
+ elsif postgresql?
154
+ definitions =
155
+ connection.select_values(<<-SQL)
156
+ SELECT pg_get_constraintdef(oid, true)
157
+ FROM pg_constraint
158
+ WHERE contype = 'c'
159
+ AND convalidated
160
+ AND conrelid = #{connection.quote(table_name)}::regclass
161
+ SQL
162
+
163
+ definitions.map { |definition| definition[/CHECK \((.+)\)/m, 1] }
164
+ else
165
+ # We don't support this Rails/database combination yet.
166
+ []
167
+ end
168
+ end
169
+
170
+ def models(except: [])
171
+ ActiveRecord::Base.descendants.reject do |model|
172
+ model.name.start_with?("HABTM_") || except.include?(model.name)
173
+ end
53
174
  end
54
175
 
55
- def eager_load!
56
- Rails.application.eager_load!
176
+ def underscored_name
177
+ self.class.underscored_name
57
178
  end
58
179
 
59
- def models
60
- ActiveRecord::Base.descendants
180
+ def postgresql?
181
+ ["PostgreSQL", "PostGIS"].include?(connection.adapter_name)
61
182
  end
62
183
  end
63
184
  end
@@ -4,77 +4,88 @@ require "active_record_doctor/detectors/base"
4
4
 
5
5
  module ActiveRecordDoctor
6
6
  module Detectors
7
- # Detect indexes whose function can be overtaken by other indexes. For example, an index on columns A, B, and C
8
- # can also serve as an index on A and A, B.
9
- class ExtraneousIndexes < Base
10
- @description = "Detect extraneous indexes"
7
+ class ExtraneousIndexes < Base # :nodoc:
8
+ @description = "identify indexes that can be dropped without degrading performance"
9
+ @config = {
10
+ ignore_tables: {
11
+ description: "tables whose indexes should never be reported as extraneous",
12
+ global: true
13
+ },
14
+ ignore_indexes: {
15
+ description: "indexes that should never be reported as extraneous",
16
+ global: true
17
+ }
18
+ }
11
19
 
12
- def run
13
- problems(subindexes_of_multi_column_indexes + indexed_primary_keys)
20
+ private
21
+
22
+ def message(extraneous_index:, replacement_indexes:)
23
+ if replacement_indexes.nil?
24
+ "remove #{extraneous_index} - coincides with the primary key on the table"
25
+ else
26
+ "remove #{extraneous_index} - can be replaced by #{replacement_indexes.join(' or ')}"
27
+ end
14
28
  end
15
29
 
16
- private
30
+ def detect
31
+ subindexes_of_multi_column_indexes
32
+ indexed_primary_keys
33
+ end
17
34
 
18
35
  def subindexes_of_multi_column_indexes
19
- tables.reject do |table|
20
- table == "schema_migrations"
21
- end.flat_map do |table|
36
+ tables(except: config(:ignore_tables)).each do |table|
22
37
  indexes = indexes(table)
23
- maximum_indexes = indexes.select do |index|
24
- maximal?(indexes, index)
25
- end
26
38
 
27
- indexes.reject do |index|
28
- maximum_indexes.include?(index)
29
- end.map do |extraneous_index|
30
- [
31
- extraneous_index.name,
32
- [
33
- :multi_column,
34
- maximum_indexes.select do |maximum_index|
35
- cover?(maximum_index, extraneous_index)
36
- end.map(&:name).sort
37
- ].flatten(1)
38
- ]
39
+ indexes.each do |index|
40
+ next if config(:ignore_indexes).include?(index.name)
41
+
42
+ replacement_indexes = indexes.select do |other_index|
43
+ index != other_index && replaceable_with?(index, other_index)
44
+ end
45
+
46
+ next if replacement_indexes.empty?
47
+
48
+ problem!(
49
+ extraneous_index: index.name,
50
+ replacement_indexes: replacement_indexes.map(&:name).sort
51
+ )
39
52
  end
40
53
  end
41
54
  end
42
55
 
43
56
  def indexed_primary_keys
44
- @indexed_primary_keys ||= tables.reject do |table|
45
- table == "schema_migrations"
46
- end.map do |table|
47
- [
48
- table,
49
- indexes(table).select do |index|
50
- index.columns == ["id"]
51
- end
52
- ]
53
- end.flat_map do |table, indexes|
54
- indexes.map do |index|
55
- [index.name, [:primary_key, table]]
57
+ tables(except: config(:ignore_tables)).each do |table|
58
+ indexes(table).each do |index|
59
+ next if config(:ignore_indexes).include?(index.name)
60
+
61
+ primary_key = connection.primary_key(table)
62
+ next if index.columns != [primary_key] || index.where
63
+
64
+ problem!(extraneous_index: index.name, replacement_indexes: nil)
56
65
  end
57
66
  end
58
67
  end
59
68
 
60
- def maximal?(indexes, index)
61
- indexes.all? do |another_index|
62
- index == another_index || !cover?(another_index, index)
63
- end
64
- end
69
+ def replaceable_with?(index1, index2)
70
+ return false if index1.type != index2.type
71
+ return false if index1.using != index2.using
72
+ return false if index1.where != index2.where
73
+ return false if opclasses(index1) != opclasses(index2)
65
74
 
66
- # Does lhs cover rhs?
67
- def cover?(lhs, rhs)
68
- case [lhs.unique, rhs.unique]
75
+ case [index1.unique, index2.unique]
69
76
  when [true, true]
70
- lhs.columns == rhs.columns
71
- when [false, true]
77
+ (index2.columns - index1.columns).empty?
78
+ when [true, false]
72
79
  false
73
80
  else
74
- prefix?(rhs, lhs)
81
+ prefix?(index1, index2)
75
82
  end
76
83
  end
77
84
 
85
+ def opclasses(index)
86
+ index.respond_to?(:opclasses) ? index.opclasses : nil
87
+ end
88
+
78
89
  def prefix?(lhs, rhs)
79
90
  lhs.columns.count <= rhs.columns.count &&
80
91
  rhs.columns[0...lhs.columns.count] == lhs.columns
@@ -4,32 +4,40 @@ require "active_record_doctor/detectors/base"
4
4
 
5
5
  module ActiveRecordDoctor
6
6
  module Detectors
7
- # Find instances of boolean column presence validations that use presence/absence instead of includes/excludes.
8
- class IncorrectBooleanPresenceValidation < Base
9
- @description = "Detect boolean columns with presence/absence instead of includes/excludes validators"
10
-
11
- def run
12
- eager_load!
13
-
14
- problems(hash_from_pairs(models.reject do |model|
15
- model.table_name.nil? ||
16
- model.table_name == "schema_migrations" ||
17
- !table_exists?(model.table_name)
18
- end.map do |model|
19
- [
20
- model.name,
21
- connection.columns(model.table_name).select do |column|
22
- column.type == :boolean &&
23
- has_presence_validator?(model, column)
24
- end.map(&:name)
25
- ]
26
- end.reject do |_model_name, columns|
27
- columns.empty?
28
- end))
29
- end
7
+ class IncorrectBooleanPresenceValidation < Base # :nodoc:
8
+ @description = "detect persence (instead of inclusion) validators on boolean columns"
9
+ @config = {
10
+ ignore_models: {
11
+ description: "models whose validators should not be checked",
12
+ global: true
13
+ },
14
+ ignore_attributes: {
15
+ description: "attributes, written as Model.attribute, whose validators should not be checked"
16
+ }
17
+ }
30
18
 
31
19
  private
32
20
 
21
+ def message(model:, attribute:)
22
+ # rubocop:disable Layout/LineLength
23
+ "replace the `presence` validator on #{model}.#{attribute} with `inclusion` - `presence` can't be used on booleans"
24
+ # rubocop:enable Layout/LineLength
25
+ end
26
+
27
+ def detect
28
+ models(except: config(:ignore_models)).each do |model|
29
+ next unless model.table_exists?
30
+
31
+ connection.columns(model.table_name).each do |column|
32
+ next if config(:ignore_attributes).include?("#{model.name}.#{column.name}")
33
+ next unless column.type == :boolean
34
+ next unless has_presence_validator?(model, column)
35
+
36
+ problem!(model: model.name, attribute: column.name)
37
+ end
38
+ end
39
+ end
40
+
33
41
  def has_presence_validator?(model, column)
34
42
  model.validators.any? do |validator|
35
43
  validator.kind == :presence && validator.attributes.include?(column.name.to_sym)