active_record_doctor 1.4.1 → 1.7.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (84) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +140 -10
  3. data/lib/active_record_doctor.rb +15 -1
  4. data/lib/active_record_doctor/printers/io_printer.rb +63 -7
  5. data/lib/active_record_doctor/railtie.rb +1 -1
  6. data/lib/active_record_doctor/tasks.rb +6 -0
  7. data/lib/active_record_doctor/tasks/base.rb +86 -0
  8. data/lib/active_record_doctor/tasks/extraneous_indexes.rb +5 -26
  9. data/lib/active_record_doctor/tasks/incorrect_boolean_presence_validation.rb +37 -0
  10. data/lib/active_record_doctor/tasks/missing_foreign_keys.rb +7 -28
  11. data/lib/active_record_doctor/tasks/missing_non_null_constraint.rb +56 -0
  12. data/lib/active_record_doctor/tasks/missing_presence_validation.rb +75 -0
  13. data/lib/active_record_doctor/tasks/missing_unique_indexes.rb +57 -0
  14. data/lib/active_record_doctor/tasks/undefined_table_references.rb +22 -21
  15. data/lib/active_record_doctor/tasks/unindexed_deleted_at.rb +23 -0
  16. data/lib/active_record_doctor/tasks/unindexed_foreign_keys.rb +7 -28
  17. data/lib/active_record_doctor/version.rb +1 -1
  18. data/lib/tasks/active_record_doctor.rake +33 -0
  19. data/test/active_record_doctor/printers/io_printer_test.rb +15 -7
  20. data/test/active_record_doctor/tasks/extraneous_indexes_test.rb +69 -19
  21. data/test/active_record_doctor/tasks/incorrect_boolean_presence_validation_test.rb +38 -0
  22. data/test/active_record_doctor/tasks/missing_foreign_keys_test.rb +17 -13
  23. data/test/active_record_doctor/tasks/missing_non_null_constraint_test.rb +113 -0
  24. data/test/active_record_doctor/tasks/missing_presence_validation_test.rb +115 -0
  25. data/test/active_record_doctor/tasks/missing_unique_indexes_test.rb +126 -0
  26. data/test/active_record_doctor/tasks/undefined_table_references_test.rb +39 -11
  27. data/test/active_record_doctor/tasks/unindexed_deleted_at_test.rb +59 -0
  28. data/test/active_record_doctor/tasks/unindexed_foreign_keys_test.rb +17 -13
  29. data/test/setup.rb +97 -0
  30. metadata +58 -117
  31. data/Rakefile +0 -28
  32. data/lib/active_record_doctor/compatibility.rb +0 -11
  33. data/lib/tasks/active_record_doctor_tasks.rake +0 -22
  34. data/test/dummy/README.rdoc +0 -28
  35. data/test/dummy/Rakefile +0 -6
  36. data/test/dummy/app/assets/javascripts/application.js +0 -13
  37. data/test/dummy/app/assets/stylesheets/application.css +0 -15
  38. data/test/dummy/app/controllers/application_controller.rb +0 -5
  39. data/test/dummy/app/helpers/application_helper.rb +0 -2
  40. data/test/dummy/app/models/application_record.rb +0 -3
  41. data/test/dummy/app/models/comment.rb +0 -3
  42. data/test/dummy/app/models/contract.rb +0 -3
  43. data/test/dummy/app/models/employer.rb +0 -2
  44. data/test/dummy/app/models/profile.rb +0 -2
  45. data/test/dummy/app/models/user.rb +0 -3
  46. data/test/dummy/app/views/layouts/application.html.erb +0 -14
  47. data/test/dummy/bin/bundle +0 -3
  48. data/test/dummy/bin/rails +0 -4
  49. data/test/dummy/bin/rake +0 -4
  50. data/test/dummy/bin/setup +0 -29
  51. data/test/dummy/config.ru +0 -4
  52. data/test/dummy/config/application.rb +0 -23
  53. data/test/dummy/config/boot.rb +0 -5
  54. data/test/dummy/config/database.yml +0 -19
  55. data/test/dummy/config/database.yml.travis +0 -5
  56. data/test/dummy/config/environment.rb +0 -5
  57. data/test/dummy/config/environments/development.rb +0 -41
  58. data/test/dummy/config/environments/production.rb +0 -79
  59. data/test/dummy/config/environments/test.rb +0 -47
  60. data/test/dummy/config/initializers/assets.rb +0 -11
  61. data/test/dummy/config/initializers/backtrace_silencers.rb +0 -7
  62. data/test/dummy/config/initializers/cookies_serializer.rb +0 -3
  63. data/test/dummy/config/initializers/filter_parameter_logging.rb +0 -4
  64. data/test/dummy/config/initializers/inflections.rb +0 -16
  65. data/test/dummy/config/initializers/mime_types.rb +0 -4
  66. data/test/dummy/config/initializers/session_store.rb +0 -3
  67. data/test/dummy/config/initializers/wrap_parameters.rb +0 -14
  68. data/test/dummy/config/locales/en.yml +0 -23
  69. data/test/dummy/config/routes.rb +0 -56
  70. data/test/dummy/config/secrets.yml +0 -22
  71. data/test/dummy/db/migrate/20160213101213_create_employers.rb +0 -13
  72. data/test/dummy/db/migrate/20160213101221_create_users.rb +0 -23
  73. data/test/dummy/db/migrate/20160213101232_create_profiles.rb +0 -12
  74. data/test/dummy/db/migrate/20160604081452_create_comments.rb +0 -11
  75. data/test/dummy/db/migrate/base_migration.rb +0 -5
  76. data/test/dummy/db/schema.rb +0 -63
  77. data/test/dummy/log/development.log +0 -136
  78. data/test/dummy/log/test.log +0 -1720
  79. data/test/dummy/public/404.html +0 -67
  80. data/test/dummy/public/422.html +0 -67
  81. data/test/dummy/public/500.html +0 -66
  82. data/test/dummy/public/favicon.ico +0 -0
  83. data/test/support/spy_printer.rb +0 -43
  84. data/test/test_helper.rb +0 -20
@@ -1,29 +1,16 @@
1
- require "active_record_doctor/compatibility"
2
- require "active_record_doctor/printers/io_printer"
1
+ require "active_record_doctor/tasks/base"
3
2
 
4
3
  module ActiveRecordDoctor
5
4
  module Tasks
6
- class ExtraneousIndexes
7
- include Compatibility
8
-
9
- def self.run
10
- new.run
11
- end
12
-
13
- def initialize(printer: ActiveRecordDoctor::Printers::IOPrinter.new)
14
- @printer = printer
15
- end
5
+ class ExtraneousIndexes < Base
6
+ @description = 'Detect extraneous indexes'
16
7
 
17
8
  def run
18
- @printer.print_extraneous_indexes(extraneous_indexes)
9
+ success(subindexes_of_multi_column_indexes + indexed_primary_keys)
19
10
  end
20
11
 
21
12
  private
22
13
 
23
- def extraneous_indexes
24
- subindexes_of_multi_column_indexes + indexed_primary_keys
25
- end
26
-
27
14
  def subindexes_of_multi_column_indexes
28
15
  tables.reject do |table|
29
16
  "schema_migrations" == table
@@ -90,15 +77,7 @@ module ActiveRecordDoctor
90
77
  end
91
78
 
92
79
  def indexes(table_name)
93
- @connection.indexes(table_name)
94
- end
95
-
96
- def tables
97
- @tables ||= connection_tables
98
- end
99
-
100
- def connection
101
- @connection ||= ActiveRecord::Base.connection
80
+ super.select { |index| index.columns.kind_of?(Array) }
102
81
  end
103
82
  end
104
83
  end
@@ -0,0 +1,37 @@
1
+ require "active_record_doctor/tasks/base"
2
+
3
+ module ActiveRecordDoctor
4
+ module Tasks
5
+ class IncorrectBooleanPresenceValidation < Base
6
+ @description = 'Detect boolean columns with presence/absence instead of includes/excludes validators'
7
+
8
+ def run
9
+ eager_load!
10
+
11
+ success(hash_from_pairs(models.reject do |model|
12
+ model.table_name.nil? ||
13
+ model.table_name == 'schema_migrations' ||
14
+ !table_exists?(model.table_name)
15
+ end.map do |model|
16
+ [
17
+ model.name,
18
+ connection.columns(model.table_name).select do |column|
19
+ column.type == :boolean &&
20
+ has_presence_validator?(model, column)
21
+ end.map(&:name)
22
+ ]
23
+ end.reject do |model_name, columns|
24
+ columns.empty?
25
+ end))
26
+ end
27
+
28
+ private
29
+
30
+ def has_presence_validator?(model, column)
31
+ model.validators.any? do |validator|
32
+ validator.kind == :presence && validator.attributes.include?(column.name.to_sym)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,27 +1,12 @@
1
- require "active_record_doctor/compatibility"
2
- require "active_record_doctor/printers/io_printer"
1
+ require "active_record_doctor/tasks/base"
3
2
 
4
3
  module ActiveRecordDoctor
5
4
  module Tasks
6
- class MissingForeignKeys
7
- include Compatibility
8
-
9
- def self.run
10
- new.run
11
- end
12
-
13
- def initialize(printer: ActiveRecordDoctor::Printers::IOPrinter.new)
14
- @printer = printer
15
- end
5
+ class MissingForeignKeys < Base
6
+ @description = 'Detect association columns without a foreign key constraint'
16
7
 
17
8
  def run
18
- @printer.print_missing_foreign_keys(missing_foreign_keys)
19
- end
20
-
21
- private
22
-
23
- def missing_foreign_keys
24
- hash_from_pairs(connection_tables.select do |table|
9
+ success(hash_from_pairs(tables.select do |table|
25
10
  "schema_migrations" != table
26
11
  end.map do |table|
27
12
  [
@@ -37,9 +22,11 @@ module ActiveRecordDoctor
37
22
  ]
38
23
  end.select do |table, columns|
39
24
  !columns.empty?
40
- end)
25
+ end))
41
26
  end
42
27
 
28
+ private
29
+
43
30
  def id?(table, column)
44
31
  column.name.end_with?("_id")
45
32
  end
@@ -56,14 +43,6 @@ module ActiveRecordDoctor
56
43
  another_column.name == type_column_name
57
44
  end
58
45
  end
59
-
60
- def connection
61
- @connection ||= ActiveRecord::Base.connection
62
- end
63
-
64
- def hash_from_pairs(pairs)
65
- Hash[*pairs.flatten(1)]
66
- end
67
46
  end
68
47
  end
69
48
  end
@@ -0,0 +1,56 @@
1
+ require "active_record_doctor/tasks/base"
2
+
3
+ module ActiveRecordDoctor
4
+ module Tasks
5
+ class MissingNonNullConstraint < Base
6
+ @description = 'Detect presence validators not backed by a non-NULL constraint'
7
+
8
+ def run
9
+ eager_load!
10
+
11
+ success(hash_from_pairs(models.reject do |model|
12
+ model.table_name.nil? ||
13
+ model.table_name == 'schema_migrations' ||
14
+ !table_exists?(model.table_name)
15
+ end.map do |model|
16
+ [
17
+ model.table_name,
18
+ connection.columns(model.table_name).select do |column|
19
+ validator_needed?(model, column) &&
20
+ has_mandatory_presence_validator?(model, column) &&
21
+ column.null
22
+ end.map(&:name)
23
+ ]
24
+ end.reject do |model_name, columns|
25
+ columns.empty?
26
+ end))
27
+ end
28
+
29
+ private
30
+
31
+ def validator_needed?(model, column)
32
+ ![model.primary_key, 'created_at', 'updated_at'].include?(column.name)
33
+ end
34
+
35
+ def has_mandatory_presence_validator?(model, column)
36
+ # A foreign key can be validates via the column name (e.g. company_id)
37
+ # or the association name (e.g. company). We collect the allowed names
38
+ # in an array to check for their presence in the validator definition
39
+ # in one go.
40
+ attribute_name_forms = [column.name.to_sym]
41
+ belongs_to = model.reflect_on_all_associations(:belongs_to).find do |reflection|
42
+ reflection.foreign_key == column.name
43
+ end
44
+ attribute_name_forms << belongs_to.name.to_sym if belongs_to
45
+
46
+ model.validators.any? do |validator|
47
+ validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
48
+ (validator.attributes & attribute_name_forms).present? &&
49
+ !validator.options[:allow_nil] &&
50
+ !validator.options[:if] &&
51
+ !validator.options[:unless]
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,75 @@
1
+ require "active_record_doctor/tasks/base"
2
+
3
+ module ActiveRecordDoctor
4
+ module Tasks
5
+ class MissingPresenceValidation < Base
6
+ @description = 'Detect non-NULL columns without a presence validator'
7
+
8
+ def run
9
+ eager_load!
10
+
11
+ success(hash_from_pairs(models.reject do |model|
12
+ model.table_name.nil? ||
13
+ model.table_name == 'schema_migrations' ||
14
+ !table_exists?(model.table_name)
15
+ end.map do |model|
16
+ [
17
+ model.name,
18
+ connection.columns(model.table_name).select do |column|
19
+ validator_needed?(model, column) &&
20
+ !validator_present?(model, column)
21
+ end.map(&:name)
22
+ ]
23
+ end.reject do |model_name, columns|
24
+ columns.empty?
25
+ end))
26
+ end
27
+
28
+ private
29
+
30
+ def validator_needed?(model, column)
31
+ ![model.primary_key, 'created_at', 'updated_at'].include?(column.name) &&
32
+ !column.null
33
+ end
34
+
35
+ def validator_present?(model, column)
36
+ if column.type == :boolean
37
+ inclusion_validator_present?(model, column) ||
38
+ exclusion_validator_present?(model, column)
39
+ else
40
+ presence_validator_present?(model, column)
41
+ end
42
+ end
43
+
44
+ def inclusion_validator_present?(model, column)
45
+ model.validators.any? do |validator|
46
+ validator.is_a?(ActiveModel::Validations::InclusionValidator) &&
47
+ validator.attributes.include?(column.name.to_sym) &&
48
+ !validator.options.fetch(:in, []).include?(nil)
49
+ end
50
+ end
51
+
52
+ def exclusion_validator_present?(model, column)
53
+ model.validators.any? do |validator|
54
+ validator.is_a?(ActiveModel::Validations::ExclusionValidator) &&
55
+ validator.attributes.include?(column.name.to_sym) &&
56
+ validator.options.fetch(:in, []).include?(nil)
57
+ end
58
+ end
59
+
60
+ def presence_validator_present?(model, column)
61
+ allowed_attributes = [column.name.to_sym]
62
+
63
+ belongs_to = model.reflect_on_all_associations(:belongs_to).find do |reflection|
64
+ reflection.foreign_key == column.name
65
+ end
66
+ allowed_attributes << belongs_to.name.to_sym if belongs_to
67
+
68
+ model.validators.any? do |validator|
69
+ validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
70
+ (validator.attributes & allowed_attributes).present?
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,57 @@
1
+ require "active_record_doctor/tasks/base"
2
+
3
+ module ActiveRecordDoctor
4
+ module Tasks
5
+ class MissingUniqueIndexes < Base
6
+ @description = 'Detect columns covered by a uniqueness validator without a unique index'
7
+
8
+ def run
9
+ eager_load!
10
+
11
+ success(hash_from_pairs(models.reject do |model|
12
+ model.table_name.nil?
13
+ end.map do |model|
14
+ [
15
+ model.table_name,
16
+ model.validators.select do |validator|
17
+ table_name = model.table_name
18
+ scope = validator.options.fetch(:scope, [])
19
+
20
+ validator.is_a?(ActiveRecord::Validations::UniquenessValidator) &&
21
+ supported_validator?(validator) &&
22
+ !unique_index?(table_name, validator.attributes, scope)
23
+ end.map do |validator|
24
+ scope = Array(validator.options.fetch(:scope, []))
25
+ attributes = validator.attributes
26
+ (scope + attributes).map(&:to_s)
27
+ end
28
+ ]
29
+ end.reject do |_table_name, indexes|
30
+ indexes.empty?
31
+ end))
32
+ end
33
+
34
+ private
35
+
36
+ def supported_validator?(validator)
37
+ validator.options[:if].nil? &&
38
+ validator.options[:unless].nil? &&
39
+ validator.options[:conditions].nil? &&
40
+
41
+ # In Rails 6, default option values are no longer explicitly set on
42
+ # options so if the key is absent we must fetch the default value
43
+ # ourselves. case_sensitive is the default in 4.2+ so it's safe to
44
+ # put true literally.
45
+ validator.options.fetch(:case_sensitive, true)
46
+ end
47
+
48
+ def unique_index?(table_name, columns, scope)
49
+ columns = (Array(scope) + columns).map(&:to_s)
50
+
51
+ indexes(table_name).any? do |index|
52
+ index.columns.to_set == columns.to_set && index.unique
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,33 +1,34 @@
1
- require "active_record_doctor/compatibility"
2
- require "active_record_doctor/printers/io_printer"
1
+ require "active_record_doctor/tasks/base"
3
2
 
4
3
  module ActiveRecordDoctor
5
4
  module Tasks
6
- class UndefinedTableReferences
7
- include Compatibility
8
-
9
- def self.run
10
- new.run
11
- end
12
-
13
- def initialize(printer: ActiveRecordDoctor::Printers::IOPrinter.new)
14
- @printer = printer
15
- end
5
+ class UndefinedTableReferences < Base
6
+ @description = 'Detect models referencing undefined tables or views'
16
7
 
17
8
  def run
18
- @printer.print_undefined_table_references(undefined_table_references)
19
- undefined_table_references.present? ? 1 : 0
20
- end
21
-
22
- private
9
+ eager_load!
23
10
 
24
- def undefined_table_references
25
- Rails.application.eager_load!
11
+ # If we can't list views due to old Rails version or unsupported
12
+ # database then existing_views is nil. We inform the caller we haven't
13
+ # consulted views so that it can display an appropriate warning.
14
+ existing_views = views
26
15
 
27
- ActiveRecord::Base.descendants.select do |model|
16
+ offending_models = models.select do |model|
28
17
  model.table_name.present? &&
29
- !model.connection.tables.include?(model.table_name)
18
+ !tables.include?(model.table_name) &&
19
+ existing_views &&
20
+ !existing_views.include?(model.table_name)
21
+ end.map do |model|
22
+ [model.name, model.table_name]
30
23
  end
24
+
25
+ [
26
+ [
27
+ offending_models, # Actual results
28
+ !existing_views.nil? # true if views were checked, false otherwise
29
+ ],
30
+ offending_models.blank?
31
+ ]
31
32
  end
32
33
  end
33
34
  end
@@ -0,0 +1,23 @@
1
+ require "active_record_doctor/tasks/base"
2
+
3
+ module ActiveRecordDoctor
4
+ module Tasks
5
+ class UnindexedDeletedAt < Base
6
+ COLUMNS = %w[deleted_at discarded_at].freeze
7
+ PATTERN = COLUMNS.join('|').freeze
8
+ @description = 'Detect unindexed deleted_at columns'
9
+
10
+ def run
11
+ success(connection.tables.select do |table|
12
+ connection.columns(table).any? { |column| column.name =~ /^#{PATTERN}$/ }
13
+ end.flat_map do |table|
14
+ connection.indexes(table).reject do |index|
15
+ index.where =~ /\b#{PATTERN}\s+IS\s+NULL\b/i
16
+ end.map do |index|
17
+ index.name
18
+ end
19
+ end)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,27 +1,12 @@
1
- require "active_record_doctor/compatibility"
2
- require "active_record_doctor/printers/io_printer"
1
+ require "active_record_doctor/tasks/base"
3
2
 
4
3
  module ActiveRecordDoctor
5
4
  module Tasks
6
- class UnindexedForeignKeys
7
- include Compatibility
8
-
9
- def self.run
10
- new.run
11
- end
12
-
13
- def initialize(printer: ActiveRecordDoctor::Printers::IOPrinter.new)
14
- @printer = printer
15
- end
5
+ class UnindexedForeignKeys < Base
6
+ @description = 'Detect foreign keys without an index on them'
16
7
 
17
8
  def run
18
- @printer.print_unindexed_foreign_keys(unindexed_foreign_keys)
19
- end
20
-
21
- private
22
-
23
- def unindexed_foreign_keys
24
- hash_from_pairs(connection_tables.select do |table|
9
+ success(hash_from_pairs(tables.select do |table|
25
10
  "schema_migrations" != table
26
11
  end.map do |table|
27
12
  [
@@ -34,9 +19,11 @@ module ActiveRecordDoctor
34
19
  ]
35
20
  end.select do |table, columns|
36
21
  !columns.empty?
37
- end)
22
+ end))
38
23
  end
39
24
 
25
+ private
26
+
40
27
  def foreign_key?(table, column)
41
28
  column.name.end_with?("_id")
42
29
  end
@@ -53,14 +40,6 @@ module ActiveRecordDoctor
53
40
  index.columns == [type_column_name, column.name]
54
41
  end
55
42
  end
56
-
57
- def connection
58
- @connection ||= ActiveRecord::Base.connection
59
- end
60
-
61
- def hash_from_pairs(pairs)
62
- Hash[*pairs.flatten(1)]
63
- end
64
43
  end
65
44
  end
66
45
  end