active_record_doctor 1.8.0 → 1.10.0

Sign up to get free protection for your applications and to get access to all the features.
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)