active_record_doctor 1.7.2 → 1.8.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +29 -0
  3. data/lib/active_record_doctor.rb +16 -12
  4. data/lib/active_record_doctor/detectors.rb +13 -0
  5. data/lib/active_record_doctor/detectors/base.rb +64 -0
  6. data/lib/active_record_doctor/{tasks → detectors}/extraneous_indexes.rb +11 -7
  7. data/lib/active_record_doctor/{tasks → detectors}/incorrect_boolean_presence_validation.rb +9 -6
  8. data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +71 -0
  9. data/lib/active_record_doctor/{tasks → detectors}/missing_foreign_keys.rb +13 -10
  10. data/lib/active_record_doctor/{tasks → detectors}/missing_non_null_constraint.rb +11 -7
  11. data/lib/active_record_doctor/{tasks → detectors}/missing_presence_validation.rb +11 -8
  12. data/lib/active_record_doctor/{tasks → detectors}/missing_unique_indexes.rb +8 -4
  13. data/lib/active_record_doctor/{tasks → detectors}/undefined_table_references.rb +11 -12
  14. data/lib/active_record_doctor/{tasks → detectors}/unindexed_deleted_at.rb +12 -6
  15. data/lib/active_record_doctor/{tasks → detectors}/unindexed_foreign_keys.rb +13 -10
  16. data/lib/active_record_doctor/printers.rb +3 -1
  17. data/lib/active_record_doctor/printers/io_printer.rb +63 -35
  18. data/lib/active_record_doctor/railtie.rb +2 -0
  19. data/lib/active_record_doctor/task.rb +28 -0
  20. data/lib/active_record_doctor/version.rb +3 -1
  21. data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +15 -11
  22. data/lib/tasks/active_record_doctor.rake +25 -25
  23. data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +67 -0
  24. data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +36 -0
  25. data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +117 -0
  26. data/test/active_record_doctor/detectors/missing_foreign_keys_test.rb +24 -0
  27. data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +102 -0
  28. data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +107 -0
  29. data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +114 -0
  30. data/test/active_record_doctor/detectors/undefined_table_references_test.rb +44 -0
  31. data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +67 -0
  32. data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +26 -0
  33. data/test/active_record_doctor/printers/io_printer_test.rb +14 -9
  34. data/test/model_factory.rb +78 -0
  35. data/test/setup.rb +69 -40
  36. metadata +70 -64
  37. data/lib/active_record_doctor/tasks.rb +0 -10
  38. data/lib/active_record_doctor/tasks/base.rb +0 -86
  39. data/test/active_record_doctor/tasks/extraneous_indexes_test.rb +0 -77
  40. data/test/active_record_doctor/tasks/incorrect_boolean_presence_validation_test.rb +0 -38
  41. data/test/active_record_doctor/tasks/missing_foreign_keys_test.rb +0 -23
  42. data/test/active_record_doctor/tasks/missing_non_null_constraint_test.rb +0 -113
  43. data/test/active_record_doctor/tasks/missing_presence_validation_test.rb +0 -115
  44. data/test/active_record_doctor/tasks/missing_unique_indexes_test.rb +0 -126
  45. data/test/active_record_doctor/tasks/undefined_table_references_test.rb +0 -47
  46. data/test/active_record_doctor/tasks/unindexed_deleted_at_test.rb +0 -59
  47. data/test/active_record_doctor/tasks/unindexed_foreign_keys_test.rb +0 -23
@@ -1,14 +1,18 @@
1
- require "active_record_doctor/tasks/base"
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
2
4
 
3
5
  module ActiveRecordDoctor
4
- module Tasks
6
+ module Detectors
7
+ # Detect columns covered by a uniqueness validation that lack the corresponding unique index thus risking duplicate
8
+ # inserts.
5
9
  class MissingUniqueIndexes < Base
6
- @description = 'Detect columns covered by a uniqueness validator without a unique index'
10
+ @description = "Detect columns covered by a uniqueness validator without a unique index"
7
11
 
8
12
  def run
9
13
  eager_load!
10
14
 
11
- success(hash_from_pairs(models.reject do |model|
15
+ problems(hash_from_pairs(models.reject do |model|
12
16
  model.table_name.nil?
13
17
  end.map do |model|
14
18
  [
@@ -1,9 +1,12 @@
1
- require "active_record_doctor/tasks/base"
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
2
4
 
3
5
  module ActiveRecordDoctor
4
- module Tasks
6
+ module Detectors
7
+ # Find models referencing non-existent database tables or views.
5
8
  class UndefinedTableReferences < Base
6
- @description = 'Detect models referencing undefined tables or views'
9
+ @description = "Detect models referencing undefined tables or views"
7
10
 
8
11
  def run
9
12
  eager_load!
@@ -16,19 +19,15 @@ module ActiveRecordDoctor
16
19
  offending_models = models.select do |model|
17
20
  model.table_name.present? &&
18
21
  !tables.include?(model.table_name) &&
19
- existing_views &&
20
- !existing_views.include?(model.table_name)
22
+ (
23
+ existing_views.nil? ||
24
+ !existing_views.include?(model.table_name)
25
+ )
21
26
  end.map do |model|
22
27
  [model.name, model.table_name]
23
28
  end
24
29
 
25
- [
26
- [
27
- offending_models, # Actual results
28
- !existing_views.nil? # true if views were checked, false otherwise
29
- ],
30
- offending_models.blank?
31
- ]
30
+ problems(offending_models, views_checked: !existing_views.nil?)
32
31
  end
33
32
  end
34
33
  end
@@ -1,14 +1,20 @@
1
- require "active_record_doctor/tasks/base"
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
2
4
 
3
5
  module ActiveRecordDoctor
4
- module Tasks
6
+ module Detectors
7
+ # Find unindexed deleted_at columns.
5
8
  class UnindexedDeletedAt < Base
6
- COLUMNS = %w[deleted_at discarded_at].freeze
7
- PATTERN = COLUMNS.join('|').freeze
8
- @description = 'Detect unindexed deleted_at columns'
9
+ PATTERN = [
10
+ "deleted_at",
11
+ "discarded_at"
12
+ ].join("|").freeze
13
+
14
+ @description = "Detect unindexed deleted_at columns"
9
15
 
10
16
  def run
11
- success(connection.tables.select do |table|
17
+ problems(connection.tables.select do |table|
12
18
  connection.columns(table).any? { |column| column.name =~ /^#{PATTERN}$/ }
13
19
  end.flat_map do |table|
14
20
  connection.indexes(table).reject do |index|
@@ -1,30 +1,33 @@
1
- require "active_record_doctor/tasks/base"
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
2
4
 
3
5
  module ActiveRecordDoctor
4
- module Tasks
6
+ module Detectors
7
+ # Find foreign keys that lack indexes (usually recommended for performance reasons).
5
8
  class UnindexedForeignKeys < Base
6
- @description = 'Detect foreign keys without an index on them'
9
+ @description = "Detect foreign keys without an index on them"
7
10
 
8
11
  def run
9
- success(hash_from_pairs(tables.select do |table|
10
- "schema_migrations" != table
12
+ problems(hash_from_pairs(tables.reject do |table|
13
+ table == "schema_migrations"
11
14
  end.map do |table|
12
15
  [
13
16
  table,
14
17
  connection.columns(table).select do |column|
15
- foreign_key?(table, column) &&
18
+ foreign_key?(column) &&
16
19
  !indexed?(table, column) &&
17
20
  !indexed_as_polymorphic?(table, column)
18
21
  end.map(&:name)
19
22
  ]
20
- end.select do |table, columns|
21
- !columns.empty?
23
+ end.reject do |_table, columns|
24
+ columns.empty?
22
25
  end))
23
26
  end
24
27
 
25
28
  private
26
29
 
27
- def foreign_key?(table, column)
30
+ def foreign_key?(column)
28
31
  column.name.end_with?("_id")
29
32
  end
30
33
 
@@ -35,7 +38,7 @@ module ActiveRecordDoctor
35
38
  end
36
39
 
37
40
  def indexed_as_polymorphic?(table, column)
38
- type_column_name = column.name.sub(/_id\Z/, '_type')
41
+ type_column_name = column.name.sub(/_id\Z/, "_type")
39
42
  connection.indexes(table).any? do |index|
40
43
  index.columns == [type_column_name, column.name]
41
44
  end
@@ -1,4 +1,6 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActiveRecordDoctor
2
- module Printers
4
+ module Printers # :nodoc:
3
5
  end
4
6
  end
@@ -1,72 +1,79 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActiveRecordDoctor
2
4
  module Printers
5
+ # Default printer for displaying messages produced by active_record_doctor.
3
6
  class IOPrinter
4
- def initialize(io = STDOUT)
7
+ def initialize(io = $stdout)
5
8
  @io = io
6
9
  end
7
10
 
8
- def unindexed_foreign_keys(unindexed_foreign_keys)
11
+ def unindexed_foreign_keys(unindexed_foreign_keys, _options)
12
+ return if unindexed_foreign_keys.empty?
13
+
14
+ @io.puts("The following foreign keys should be indexed for performance reasons:")
9
15
  @io.puts(unindexed_foreign_keys.sort.map do |table, columns|
10
- "#{table} #{columns.sort.join(' ')}"
16
+ " #{table} #{columns.sort.join(' ')}"
11
17
  end.join("\n"))
12
18
  end
13
19
 
14
- def extraneous_indexes(extraneous_indexes)
15
- if extraneous_indexes.empty?
16
- @io.puts("No indexes are extraneous.")
17
- else
18
- @io.puts("The following indexes are extraneous and can be removed:")
19
- extraneous_indexes.each do |index, details|
20
- reason, *params = details
21
- case reason
22
- when :multi_column
23
- @io.puts(" #{index} (can be handled by #{params.join(', ')})")
24
- when :primary_key
25
- @io.puts(" #{index} (is a primary key of #{params[0]})")
26
- else
27
- fail("unknown reason #{reason.inspect}")
28
- end
20
+ def extraneous_indexes(extraneous_indexes, _options)
21
+ return if extraneous_indexes.empty?
22
+
23
+ @io.puts("The following indexes are extraneous and can be removed:")
24
+ extraneous_indexes.each do |index, details|
25
+ reason, *params = details
26
+ case reason
27
+ when :multi_column
28
+ @io.puts(" #{index} (can be handled by #{params.join(', ')})")
29
+ when :primary_key
30
+ @io.puts(" #{index} (is a primary key of #{params[0]})")
31
+ else
32
+ raise("unknown reason #{reason.inspect}")
29
33
  end
30
34
  end
31
35
  end
32
36
 
33
- def missing_foreign_keys(missing_foreign_keys)
37
+ def missing_foreign_keys(missing_foreign_keys, _options)
38
+ return if missing_foreign_keys.empty?
39
+
40
+ @io.puts("The following columns lack a foreign key constraint:")
34
41
  @io.puts(missing_foreign_keys.sort.map do |table, columns|
35
- "#{table} #{columns.sort.join(' ')}"
42
+ " #{table} #{columns.sort.join(' ')}"
36
43
  end.join("\n"))
37
44
  end
38
45
 
39
- def undefined_table_references((models, views_checked))
46
+ def undefined_table_references(models, options)
40
47
  return if models.empty?
41
48
 
42
- unless views_checked
43
- @io.puts(<<EOS)
49
+ unless options.fetch(:views_checked)
50
+ @io.puts(<<WARNING)
44
51
  WARNING: Models backed by database views are supported only in Rails 5+ OR
45
52
  Rails 4.2 + PostgreSQL. It seems this is NOT your setup. Therefore, such models
46
53
  will be erroneously reported below as not having their underlying tables/views.
47
54
  Consider upgrading Rails or disabling this task temporarily.
48
- EOS
55
+ WARNING
49
56
  end
50
57
 
51
- @io.puts('The following models reference undefined tables:')
58
+ @io.puts("The following models reference undefined tables:")
52
59
  models.each do |model_name, table_name|
53
60
  @io.puts(" #{model_name} (the table #{table_name} is undefined)")
54
61
  end
55
62
  end
56
63
 
57
- def unindexed_deleted_at(indexes)
64
+ def unindexed_deleted_at(indexes, _options)
58
65
  return if indexes.empty?
59
66
 
60
- @io.puts('The following indexes should include `deleted_at IS NULL`:')
67
+ @io.puts("The following indexes should include `deleted_at IS NULL`:")
61
68
  indexes.each do |index|
62
69
  @io.puts(" #{index}")
63
70
  end
64
71
  end
65
72
 
66
- def missing_unique_indexes(indexes)
73
+ def missing_unique_indexes(indexes, _options)
67
74
  return if indexes.empty?
68
75
 
69
- @io.puts('The following indexes should be created to back model-level uniqueness validations:')
76
+ @io.puts("The following indexes should be created to back model-level uniqueness validations:")
70
77
  indexes.each do |table, arrays_of_columns|
71
78
  arrays_of_columns.each do |columns|
72
79
  @io.puts(" #{table}: #{columns.join(', ')}")
@@ -74,32 +81,53 @@ EOS
74
81
  end
75
82
  end
76
83
 
77
- def missing_presence_validation(missing_presence_validators)
84
+ def missing_presence_validation(missing_presence_validators, _options)
78
85
  return if missing_presence_validators.empty?
79
86
 
80
- @io.puts('The following models and columns should have presence validations:')
87
+ @io.puts("The following models and columns should have presence validations:")
81
88
  missing_presence_validators.each do |model_name, array_of_columns|
82
89
  @io.puts(" #{model_name}: #{array_of_columns.join(', ')}")
83
90
  end
84
91
  end
85
92
 
86
- def missing_non_null_constraint(missing_non_null_constraints)
93
+ def missing_non_null_constraint(missing_non_null_constraints, _options)
87
94
  return if missing_non_null_constraints.empty?
88
95
 
89
- @io.puts('The following columns should be marked as `null: false`:')
96
+ @io.puts("The following columns should be marked as `null: false`:")
90
97
  missing_non_null_constraints.each do |table, columns|
91
98
  @io.puts(" #{table}: #{columns.join(', ')}")
92
99
  end
93
100
  end
94
101
 
95
- def incorrect_boolean_presence_validation(incorrect_boolean_presence_validations)
102
+ def incorrect_boolean_presence_validation(incorrect_boolean_presence_validations, _options)
96
103
  return if incorrect_boolean_presence_validations.empty?
97
104
 
98
- @io.puts('The presence of the following boolean columns is validated incorrectly:')
105
+ @io.puts("The presence of the following boolean columns is validated incorrectly:")
99
106
  incorrect_boolean_presence_validations.each do |table, columns|
100
107
  @io.puts(" #{table}: #{columns.join(', ')}")
101
108
  end
102
109
  end
110
+
111
+ def incorrect_dependent_option(problems, _options)
112
+ return if problems.empty?
113
+
114
+ @io.puts("The following associations might be using invalid dependent settings:")
115
+ problems.each do |model, associations|
116
+ associations.each do |(name, problem)|
117
+ # rubocop:disable Layout/LineLength
118
+ message =
119
+ case problem
120
+ when :suggest_destroy then "skips callbacks that are defined on the associated model - consider changing to `dependent: :destroy` or similar"
121
+ when :suggest_delete then "loads the associated model before deleting it - consider using `dependent: :delete`"
122
+ when :suggest_delete_all then "loads models one-by-one to invoke callbacks even though the related model defines none - consider using `dependent: :delete_all`"
123
+ else next
124
+ end
125
+ # rubocop:enable Layout/LineLength
126
+
127
+ @io.puts(" #{model}: #{name} #{message}")
128
+ end
129
+ end
130
+ end
103
131
  end
104
132
  end
105
133
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActiveRecordDoctor
2
4
  class Railtie < Rails::Railtie
3
5
  rake_tasks do
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDoctor
4
+ # Rake task for running a detector and reporting its results.
5
+ class Task
6
+ DEFAULT_PRINTER = ActiveRecordDoctor::Printers::IOPrinter.new
7
+
8
+ def initialize(detector_class, printer = DEFAULT_PRINTER)
9
+ @detector_class = detector_class
10
+ @printer = printer
11
+ end
12
+
13
+ def name
14
+ @detector_class.name.demodulize.underscore.to_sym
15
+ end
16
+
17
+ def description
18
+ @detector_class.description
19
+ end
20
+
21
+ def run
22
+ problems, options = @detector_class.run
23
+ @printer.public_send(name, problems, options)
24
+
25
+ problems.empty?
26
+ end
27
+ end
28
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActiveRecordDoctor
2
- VERSION = "1.7.2"
4
+ VERSION = "1.8.0"
3
5
  end
@@ -1,9 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActiveRecordDoctor
4
+ # Generate migrations that add missing indexes to the database.
2
5
  class AddIndexesGenerator < Rails::Generators::Base
3
6
  MigrationDescription = Struct.new(:table, :columns)
4
7
 
5
- desc 'Generate migrations for the specified indexes'
6
- argument :path, type: :string, default: nil, banner: 'PATH'
8
+ desc "Generate migrations for the specified indexes"
9
+ argument :path, type: :string, default: nil, banner: "PATH"
7
10
 
8
11
  def create_migrations
9
12
  migration_descriptions = read_migration_descriptions(path)
@@ -23,10 +26,10 @@ module ActiveRecordDoctor
23
26
  table, *columns = line.split(/\s+/)
24
27
 
25
28
  if table.empty?
26
- fail("No table name in #{path} on line #{index + 1}. Ensure the line doesn't start with whitespace.")
29
+ raise("No table name in #{path} on line #{index + 1}. Ensure the line doesn't start with whitespace.")
27
30
  end
28
31
  if columns.empty?
29
- fail("No columns for table #{table} in #{path} on line #{index + 1}.")
32
+ raise("No columns for table #{table} in #{path} on line #{index + 1}.")
30
33
  end
31
34
 
32
35
  MigrationDescription.new(table, columns)
@@ -34,13 +37,16 @@ module ActiveRecordDoctor
34
37
  end
35
38
 
36
39
  def content(migration_description)
37
- <<EOF
40
+ # In order to properly indent the resulting code, we must disable the
41
+ # rubocop rule below.
42
+
43
+ <<MIGRATION
38
44
  class IndexForeignKeysIn#{migration_description.table.camelize} < ActiveRecord::Migration#{migration_version}
39
45
  def change
40
46
  #{add_indexes(migration_description)}
41
47
  end
42
48
  end
43
- EOF
49
+ MIGRATION
44
50
  end
45
51
 
46
52
  def add_indexes(migration_description)
@@ -54,12 +60,10 @@ EOF
54
60
  end
55
61
 
56
62
  def migration_version
57
- major = ActiveRecord::VERSION::MAJOR
58
- minor = ActiveRecord::VERSION::MINOR
59
- if major >= 5 && minor >= 1
60
- "[#{major}.#{minor}]"
63
+ if ActiveRecord::VERSION::STRING >= "5.1"
64
+ "[#{version}]"
61
65
  else
62
- ''
66
+ ""
63
67
  end
64
68
  end
65
69
  end
@@ -1,33 +1,33 @@
1
- require "active_record_doctor/tasks"
2
- require "active_record_doctor/tasks/unindexed_foreign_keys"
3
- require "active_record_doctor/tasks/extraneous_indexes"
4
- require "active_record_doctor/tasks/missing_foreign_keys"
5
- require "active_record_doctor/tasks/undefined_table_references"
6
- require "active_record_doctor/tasks/unindexed_deleted_at"
7
- require "active_record_doctor/tasks/missing_unique_indexes"
8
- require "active_record_doctor/tasks/missing_presence_validation"
9
- require "active_record_doctor/tasks/missing_non_null_constraint"
10
- require "active_record_doctor/tasks/incorrect_boolean_presence_validation"
1
+ # frozen_string_literal: true
11
2
 
12
- namespace :active_record_doctor do
13
- def mount(task_class)
14
- name = task_class.name.demodulize.underscore.to_sym
15
-
16
- desc task_class.description
17
- task name => :environment do
18
- result, success = task_class.run
19
- success = true if success.nil?
3
+ require "active_record_doctor/detectors"
4
+ require "active_record_doctor/detectors/unindexed_foreign_keys"
5
+ require "active_record_doctor/detectors/extraneous_indexes"
6
+ require "active_record_doctor/detectors/missing_foreign_keys"
7
+ require "active_record_doctor/detectors/undefined_table_references"
8
+ require "active_record_doctor/detectors/unindexed_deleted_at"
9
+ require "active_record_doctor/detectors/missing_unique_indexes"
10
+ require "active_record_doctor/detectors/missing_presence_validation"
11
+ require "active_record_doctor/detectors/missing_non_null_constraint"
12
+ require "active_record_doctor/detectors/incorrect_boolean_presence_validation"
13
+ require "active_record_doctor/detectors/incorrect_dependent_option"
14
+ require "active_record_doctor/task"
20
15
 
21
- printer = ActiveRecordDoctor::Printers::IOPrinter.new
22
- printer.public_send(name, result)
16
+ namespace :active_record_doctor do
17
+ tasks = ActiveRecordDoctor::Detectors.all.map do |detector_class|
18
+ ActiveRecordDoctor::Task.new(detector_class)
19
+ end
23
20
 
24
- # nil doesn't indicate a failure but rather no explicit result. We assume
25
- # success by default hence only false results in an erroneous exit code.
26
- exit(1) if success == false
21
+ tasks.each do |task|
22
+ desc task.description
23
+ task task.name => :environment do
24
+ task.run or exit(1)
27
25
  end
28
26
  end
29
27
 
30
- ActiveRecordDoctor::Tasks.all.each do |task|
31
- mount task
28
+ desc "Run all active_record_doctor tasks"
29
+ task :all => :environment do
30
+ results = tasks.map { |task| task.run }
31
+ results.all? or exit(1)
32
32
  end
33
33
  end