active_record_doctor 1.7.0 → 1.9.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +287 -43
  3. data/lib/active_record_doctor/config/default.rb +59 -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 +155 -0
  7. data/lib/active_record_doctor/detectors/extraneous_indexes.rb +114 -0
  8. data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +49 -0
  9. data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +107 -0
  10. data/lib/active_record_doctor/detectors/mismatched_foreign_key_type.rb +45 -0
  11. data/lib/active_record_doctor/detectors/missing_foreign_keys.rb +60 -0
  12. data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +72 -0
  13. data/lib/active_record_doctor/{tasks → detectors}/missing_presence_validation.rb +35 -21
  14. data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +71 -0
  15. data/lib/active_record_doctor/detectors/short_primary_key_type.rb +41 -0
  16. data/lib/active_record_doctor/detectors/undefined_table_references.rb +33 -0
  17. data/lib/active_record_doctor/detectors/unindexed_deleted_at.rb +55 -0
  18. data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +59 -0
  19. data/lib/active_record_doctor/detectors.rb +21 -0
  20. data/lib/active_record_doctor/errors.rb +226 -0
  21. data/lib/active_record_doctor/help.rb +39 -0
  22. data/lib/active_record_doctor/printers.rb +3 -1
  23. data/lib/active_record_doctor/railtie.rb +2 -0
  24. data/lib/active_record_doctor/rake/task.rb +78 -0
  25. data/lib/active_record_doctor/runner.rb +41 -0
  26. data/lib/active_record_doctor/version.rb +3 -1
  27. data/lib/active_record_doctor.rb +24 -2
  28. data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +46 -29
  29. data/lib/tasks/active_record_doctor.rake +21 -29
  30. data/test/active_record_doctor/config/loader_test.rb +120 -0
  31. data/test/active_record_doctor/config_test.rb +116 -0
  32. data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +190 -0
  33. data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +79 -0
  34. data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +295 -0
  35. data/test/active_record_doctor/detectors/mismatched_foreign_key_type_test.rb +82 -0
  36. data/test/active_record_doctor/detectors/missing_foreign_keys_test.rb +70 -0
  37. data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +216 -0
  38. data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +168 -0
  39. data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +163 -0
  40. data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +64 -0
  41. data/test/active_record_doctor/detectors/undefined_table_references_test.rb +57 -0
  42. data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +171 -0
  43. data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +78 -0
  44. data/test/active_record_doctor/runner_test.rb +42 -0
  45. data/test/generators/active_record_doctor/add_indexes/add_indexes_generator_test.rb +131 -0
  46. data/test/model_factory.rb +128 -0
  47. data/test/setup.rb +116 -0
  48. metadata +105 -156
  49. data/Rakefile +0 -28
  50. data/lib/active_record_doctor/printers/io_printer.rb +0 -105
  51. data/lib/active_record_doctor/tasks/base.rb +0 -78
  52. data/lib/active_record_doctor/tasks/extraneous_indexes.rb +0 -82
  53. data/lib/active_record_doctor/tasks/incorrect_boolean_presence_validation.rb +0 -33
  54. data/lib/active_record_doctor/tasks/missing_foreign_keys.rb +0 -46
  55. data/lib/active_record_doctor/tasks/missing_non_null_constraint.rb +0 -49
  56. data/lib/active_record_doctor/tasks/missing_unique_indexes.rb +0 -56
  57. data/lib/active_record_doctor/tasks/undefined_table_references.rb +0 -33
  58. data/lib/active_record_doctor/tasks/unindexed_deleted_at.rb +0 -19
  59. data/lib/active_record_doctor/tasks/unindexed_foreign_keys.rb +0 -43
  60. data/lib/active_record_doctor/tasks.rb +0 -7
  61. data/test/active_record_doctor/printers/io_printer_test.rb +0 -20
  62. data/test/active_record_doctor/tasks/extraneous_indexes_test.rb +0 -81
  63. data/test/active_record_doctor/tasks/incorrect_boolean_presence_validation_test.rb +0 -33
  64. data/test/active_record_doctor/tasks/missing_foreign_keys_test.rb +0 -27
  65. data/test/active_record_doctor/tasks/missing_non_null_constraint_test.rb +0 -102
  66. data/test/active_record_doctor/tasks/missing_presence_validation_test.rb +0 -110
  67. data/test/active_record_doctor/tasks/missing_unique_indexes_test.rb +0 -95
  68. data/test/active_record_doctor/tasks/undefined_table_references_test.rb +0 -51
  69. data/test/active_record_doctor/tasks/unindexed_deleted_at_test.rb +0 -34
  70. data/test/active_record_doctor/tasks/unindexed_foreign_keys_test.rb +0 -27
  71. data/test/dummy/README.rdoc +0 -28
  72. data/test/dummy/Rakefile +0 -6
  73. data/test/dummy/app/assets/config/manifest.js +0 -1
  74. data/test/dummy/app/assets/javascripts/application.js +0 -13
  75. data/test/dummy/app/assets/stylesheets/application.css +0 -15
  76. data/test/dummy/app/controllers/application_controller.rb +0 -5
  77. data/test/dummy/app/helpers/application_helper.rb +0 -2
  78. data/test/dummy/app/models/application_record.rb +0 -3
  79. data/test/dummy/app/views/layouts/application.html.erb +0 -14
  80. data/test/dummy/bin/bundle +0 -3
  81. data/test/dummy/bin/rails +0 -4
  82. data/test/dummy/bin/rake +0 -4
  83. data/test/dummy/bin/setup +0 -29
  84. data/test/dummy/config/application.rb +0 -23
  85. data/test/dummy/config/boot.rb +0 -5
  86. data/test/dummy/config/database.yml +0 -19
  87. data/test/dummy/config/database.yml.travis +0 -5
  88. data/test/dummy/config/environment.rb +0 -5
  89. data/test/dummy/config/environments/development.rb +0 -41
  90. data/test/dummy/config/environments/production.rb +0 -79
  91. data/test/dummy/config/environments/test.rb +0 -47
  92. data/test/dummy/config/initializers/assets.rb +0 -11
  93. data/test/dummy/config/initializers/backtrace_silencers.rb +0 -7
  94. data/test/dummy/config/initializers/cookies_serializer.rb +0 -3
  95. data/test/dummy/config/initializers/filter_parameter_logging.rb +0 -4
  96. data/test/dummy/config/initializers/inflections.rb +0 -16
  97. data/test/dummy/config/initializers/mime_types.rb +0 -4
  98. data/test/dummy/config/initializers/session_store.rb +0 -3
  99. data/test/dummy/config/initializers/wrap_parameters.rb +0 -14
  100. data/test/dummy/config/locales/en.yml +0 -23
  101. data/test/dummy/config/routes.rb +0 -56
  102. data/test/dummy/config/secrets.yml +0 -22
  103. data/test/dummy/config.ru +0 -4
  104. data/test/dummy/db/migrate/base_migration.rb +0 -5
  105. data/test/dummy/db/schema.rb +0 -19
  106. data/test/dummy/log/development.log +0 -111
  107. data/test/dummy/log/test.log +0 -67508
  108. data/test/dummy/public/404.html +0 -67
  109. data/test/dummy/public/422.html +0 -67
  110. data/test/dummy/public/500.html +0 -66
  111. data/test/dummy/public/favicon.ico +0 -0
  112. data/test/support/assertions.rb +0 -11
  113. data/test/support/temping.rb +0 -25
  114. data/test/test_helper.rb +0 -17
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDoctor
4
+ module Detectors
5
+ # Base class for all active_record_doctor detectors.
6
+ class Base
7
+ class << self
8
+ attr_reader :description, :config
9
+
10
+ def run(config, io)
11
+ new(config, io).run
12
+ end
13
+
14
+ def underscored_name
15
+ name.demodulize.underscore.to_sym
16
+ end
17
+
18
+ def locals_and_globals
19
+ locals = []
20
+ globals = []
21
+
22
+ config.each do |key, metadata|
23
+ locals << key
24
+ globals << key if metadata[:global]
25
+ end
26
+
27
+ [locals, globals]
28
+ end
29
+ end
30
+
31
+ def initialize(config, io)
32
+ @problems = []
33
+ @config = config
34
+ @io = io
35
+ end
36
+
37
+ def run
38
+ @problems = []
39
+
40
+ detect
41
+ @problems.each do |problem|
42
+ @io.puts(message(**problem))
43
+ end
44
+
45
+ success = @problems.empty?
46
+ @problems = nil
47
+ success
48
+ end
49
+
50
+ private
51
+
52
+ def config(key)
53
+ local = @config.detectors.fetch(underscored_name).fetch(key)
54
+ return local if !self.class.config.fetch(key)[:global]
55
+
56
+ global = @config.globals[key]
57
+ return local if global.nil?
58
+
59
+ # Right now, all globals are arrays so we can merge them here. Once
60
+ # we add non-array globals we'll need to support per-global merging.
61
+ Array.new(local).concat(global)
62
+ end
63
+
64
+ def detect
65
+ raise("#detect should be implemented by a subclass")
66
+ end
67
+
68
+ def message(**_attrs)
69
+ raise("#message should be implemented by a subclass")
70
+ end
71
+
72
+ def problem!(**attrs)
73
+ @problems << attrs
74
+ end
75
+
76
+ def warning(message)
77
+ puts(message)
78
+ end
79
+
80
+ def connection
81
+ @connection ||= ActiveRecord::Base.connection
82
+ end
83
+
84
+ def indexes(table_name, except: [])
85
+ connection.indexes(table_name).reject do |index|
86
+ except.include?(index.name)
87
+ end
88
+ end
89
+
90
+ def tables(except: [])
91
+ tables =
92
+ if ActiveRecord::VERSION::STRING >= "5.1"
93
+ connection.tables
94
+ else
95
+ connection.data_sources
96
+ end
97
+
98
+ tables.reject do |table|
99
+ except.include?(table)
100
+ end
101
+ end
102
+
103
+ def table_exists?(table_name)
104
+ if ActiveRecord::VERSION::STRING >= "5.1"
105
+ connection.table_exists?(table_name)
106
+ else
107
+ connection.data_source_exists?(table_name)
108
+ end
109
+ end
110
+
111
+ def tables_and_views
112
+ if connection.respond_to?(:data_sources)
113
+ connection.data_sources
114
+ else
115
+ connection.tables
116
+ end
117
+ end
118
+
119
+ def primary_key(table_name)
120
+ primary_key_name = connection.primary_key(table_name)
121
+ return nil if primary_key_name.nil?
122
+
123
+ column(table_name, primary_key_name)
124
+ end
125
+
126
+ def column(table_name, column_name)
127
+ connection.columns(table_name).find { |column| column.name == column_name }
128
+ end
129
+
130
+ def views
131
+ @views ||=
132
+ if connection.respond_to?(:views)
133
+ connection.views
134
+ elsif connection.adapter_name == "PostgreSQL"
135
+ ActiveRecord::Base.connection.execute(<<-SQL).map { |tuple| tuple.fetch("relname") }
136
+ SELECT c.relname FROM pg_class c WHERE c.relkind IN ('m', 'v')
137
+ SQL
138
+ else
139
+ # We don't support this Rails/database combination yet.
140
+ nil
141
+ end
142
+ end
143
+
144
+ def models(except: [])
145
+ ActiveRecord::Base.descendants.reject do |model|
146
+ model.name.start_with?("HABTM_") || except.include?(model.name)
147
+ end
148
+ end
149
+
150
+ def underscored_name
151
+ self.class.underscored_name
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
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
+ }
19
+
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
28
+ end
29
+
30
+ def detect
31
+ subindexes_of_multi_column_indexes
32
+ indexed_primary_keys
33
+ end
34
+
35
+ def subindexes_of_multi_column_indexes
36
+ tables(except: config(:ignore_tables)).each do |table|
37
+ indexes = indexes(table)
38
+ maximal_indexes = indexes.select { |index| maximal?(indexes, index) }
39
+
40
+ indexes.each do |index|
41
+ next if maximal_indexes.include?(index)
42
+
43
+ replacement_indexes = maximal_indexes.select do |maximum_index|
44
+ cover?(maximum_index, index)
45
+ end.map(&:name).sort
46
+
47
+ next if config(:ignore_indexes).include?(index.name)
48
+
49
+ problem!(extraneous_index: index.name, replacement_indexes: replacement_indexes)
50
+ end
51
+ end
52
+ end
53
+
54
+ def indexed_primary_keys
55
+ tables(except: config(:ignore_tables)).each do |table|
56
+ indexes(table).each do |index|
57
+ next if config(:ignore_indexes).include?(index.name)
58
+ next if index.columns != ["id"]
59
+
60
+ problem!(extraneous_index: index.name, replacement_indexes: nil)
61
+ end
62
+ end
63
+ end
64
+
65
+ def maximal?(indexes, index)
66
+ indexes.all? do |another_index|
67
+ index == another_index || !cover?(another_index, index)
68
+ end
69
+ end
70
+
71
+ # Does lhs cover rhs?
72
+ def cover?(lhs, rhs)
73
+ return false unless compatible_options?(lhs, rhs)
74
+
75
+ case [lhs.unique, rhs.unique]
76
+ when [true, true]
77
+ lhs.columns == rhs.columns
78
+ when [false, true]
79
+ false
80
+ else
81
+ prefix?(rhs, lhs)
82
+ end
83
+ end
84
+
85
+ def prefix?(lhs, rhs)
86
+ lhs.columns.count <= rhs.columns.count &&
87
+ rhs.columns[0...lhs.columns.count] == lhs.columns
88
+ end
89
+
90
+ def indexes(table_name)
91
+ super.select { |index| index.columns.is_a?(Array) }
92
+ end
93
+
94
+ def compatible_options?(lhs, rhs)
95
+ lhs.type == rhs.type &&
96
+ lhs.using == rhs.using &&
97
+ lhs.where == rhs.where &&
98
+ same_opclasses?(lhs, rhs)
99
+ end
100
+
101
+ def same_opclasses?(lhs, rhs)
102
+ if ActiveRecord::VERSION::STRING >= "5.2"
103
+ rhs.columns.all? do |column|
104
+ lhs_opclass = lhs.opclasses.is_a?(Hash) ? lhs.opclasses[column] : lhs.opclasses
105
+ rhs_opclass = rhs.opclasses.is_a?(Hash) ? rhs.opclasses[column] : rhs.opclasses
106
+ lhs_opclass == rhs_opclass
107
+ end
108
+ else
109
+ true
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
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
+ }
18
+
19
+ private
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 if model.table_name.nil?
30
+ next unless table_exists?(model.table_name)
31
+
32
+ connection.columns(model.table_name).each do |column|
33
+ next if config(:ignore_attributes).include?("#{model.name}.#{column.name}")
34
+ next unless column.type == :boolean
35
+ next unless has_presence_validator?(model, column)
36
+
37
+ problem!(model: model.name, attribute: column.name)
38
+ end
39
+ end
40
+ end
41
+
42
+ def has_presence_validator?(model, column)
43
+ model.validators.any? do |validator|
44
+ validator.kind == :presence && validator.attributes.include?(column.name.to_sym)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ class IncorrectDependentOption < Base # :nodoc:
8
+ @description = "detect associations with incorrect dependent options"
9
+ @config = {
10
+ ignore_models: {
11
+ description: "models whose associations should not be checked",
12
+ global: true
13
+ },
14
+ ignore_associations: {
15
+ description: "associations, written as Model.association, that should not be checked"
16
+ }
17
+ }
18
+
19
+ private
20
+
21
+ def message(model:, association:, problem:)
22
+ # rubocop:disable Layout/LineLength
23
+ case problem
24
+ when :suggest_destroy
25
+ "use `dependent: :destroy` or similar on #{model}.#{association} - the associated model has callbacks that are currently skipped"
26
+ when :suggest_delete
27
+ "use `dependent: :delete` or similar on #{model}.#{association} - the associated model has no callbacks and can be deleted without loading"
28
+ when :suggest_delete_all
29
+ "use `dependent: :delete_all` or similar on #{model}.#{association} - associated models have no validations and can be deleted in bulk"
30
+ end
31
+ # rubocop:enable Layout/LineLength
32
+ end
33
+
34
+ def detect
35
+ models(except: config(:ignore_models)).each do |model|
36
+ next if model.table_name.nil?
37
+
38
+ associations = model.reflect_on_all_associations(:has_many) +
39
+ model.reflect_on_all_associations(:has_one) +
40
+ model.reflect_on_all_associations(:belongs_to)
41
+
42
+ associations.each do |association|
43
+ next if config(:ignore_associations).include?("#{model.name}.#{association.name}")
44
+
45
+ if callback_action(association) == :invoke && deletable?(association.klass)
46
+ suggestion =
47
+ case association.macro
48
+ when :has_many then :suggest_delete_all
49
+ when :has_one, :belongs_to then :suggest_delete
50
+ else raise("unsupported association type #{association.macro}")
51
+ end
52
+
53
+ problem!(model: model.name, association: association.name, problem: suggestion)
54
+ elsif callback_action(association) == :skip && !deletable?(association.klass)
55
+ problem!(model: model.name, association: association.name, problem: :suggest_destroy)
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ def callback_action(reflection)
62
+ case reflection.options[:dependent]
63
+ when :delete, :delete_all then :skip
64
+ when :destroy then :invoke
65
+ end
66
+ end
67
+
68
+ def deletable?(model)
69
+ !defines_destroy_callbacks?(model) &&
70
+ dependent_models(model).all? do |dependent_model|
71
+ foreign_key = foreign_key(dependent_model.table_name, model.table_name)
72
+
73
+ foreign_key.nil? ||
74
+ foreign_key.on_delete == :nullify || (
75
+ foreign_key.on_delete == :cascade && deletable?(dependent_model)
76
+ )
77
+ end
78
+ end
79
+
80
+ def defines_destroy_callbacks?(model)
81
+ # Destroying an associated model involves loading it first hence
82
+ # initialize and find are present. If they are defined on the model
83
+ # being deleted then theoretically we can't use :delete_all. It's a bit
84
+ # of an edge case as they usually are either absent or have no side
85
+ # effects but we're being pedantic -- they could be used for audit
86
+ # trial, for instance, and we don't want to skip that.
87
+ model._initialize_callbacks.present? ||
88
+ model._find_callbacks.present? ||
89
+ model._destroy_callbacks.present? ||
90
+ model._commit_callbacks.present? ||
91
+ model._rollback_callbacks.present?
92
+ end
93
+
94
+ def dependent_models(model)
95
+ reflections = model.reflect_on_all_associations(:has_many) +
96
+ model.reflect_on_all_associations(:has_one)
97
+ reflections.map(&:klass)
98
+ end
99
+
100
+ def foreign_key(from_table, to_table)
101
+ connection.foreign_keys(from_table).find do |foreign_key|
102
+ foreign_key.to_table == to_table
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ class MismatchedForeignKeyType < Base # :nodoc:
8
+ @description = "detect foreign key type mismatches"
9
+ @config = {
10
+ ignore_tables: {
11
+ description: "tables whose foreign keys should not be checked",
12
+ global: true
13
+ },
14
+ ignore_columns: {
15
+ description: "foreign keys, written as table.column, that should not be checked"
16
+ }
17
+ }
18
+
19
+ private
20
+
21
+ def message(table:, column:)
22
+ # rubocop:disable Layout/LineLength
23
+ "#{table}.#{column} references a column of different type - foreign keys should be of the same type as the referenced column"
24
+ # rubocop:enable Layout/LineLength
25
+ end
26
+
27
+ def detect
28
+ tables(except: config(:ignore_tables)).each do |table|
29
+ connection.foreign_keys(table).each do |foreign_key|
30
+ from_column = column(table, foreign_key.column)
31
+
32
+ next if config(:ignore_columns).include?("#{table}.#{from_column.name}")
33
+
34
+ to_table = foreign_key.to_table
35
+ primary_key = primary_key(to_table)
36
+
37
+ next if from_column.sql_type == primary_key.sql_type
38
+
39
+ problem!(table: table, column: from_column.name)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ class MissingForeignKeys < Base # :nodoc:
8
+ @description = "detect foreign-key-like columns lacking an actual foreign key constraint"
9
+ @config = {
10
+ ignore_tables: {
11
+ description: "tables whose columns should not be checked",
12
+ global: true
13
+ },
14
+ ignore_columns: {
15
+ description: "columns, written as table.column, that should not be checked"
16
+ }
17
+ }
18
+
19
+ private
20
+
21
+ def message(table:, column:)
22
+ "create a foreign key on #{table}.#{column} - looks like an association without a foreign key constraint"
23
+ end
24
+
25
+ def detect
26
+ tables(except: config(:ignore_tables)).each do |table|
27
+ connection.columns(table).each do |column|
28
+ next if config(:ignore_columns).include?("#{table}.#{column.name}")
29
+
30
+ # We need to skip polymorphic associations as they can reference
31
+ # multiple tables but a foreign key constraint can reference
32
+ # a single predefined table.
33
+ next unless named_like_foreign_key?(column)
34
+ next if foreign_key?(table, column)
35
+ next if polymorphic_foreign_key?(table, column)
36
+
37
+ problem!(table: table, column: column.name)
38
+ end
39
+ end
40
+ end
41
+
42
+ def named_like_foreign_key?(column)
43
+ column.name.end_with?("_id")
44
+ end
45
+
46
+ def foreign_key?(table, column)
47
+ connection.foreign_keys(table).any? do |foreign_key|
48
+ foreign_key.options[:column] == column.name
49
+ end
50
+ end
51
+
52
+ def polymorphic_foreign_key?(table, column)
53
+ type_column_name = column.name.sub(/_id\Z/, "_type")
54
+ connection.columns(table).any? do |another_column|
55
+ another_column.name == type_column_name
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ class MissingNonNullConstraint < Base # :nodoc:
8
+ @description = "detect columns whose presence is always validated but isn't enforced via a non-NULL constraint"
9
+ @config = {
10
+ ignore_tables: {
11
+ description: "tables whose columns should not be checked",
12
+ global: true
13
+ },
14
+ ignore_columns: {
15
+ description: "columns, written as table.column, that should not be checked"
16
+ }
17
+ }
18
+
19
+ private
20
+
21
+ def message(column:, table:)
22
+ "add `NOT NULL` to #{table}.#{column} - models validates its presence but it's not non-NULL in the database"
23
+ end
24
+
25
+ def detect
26
+ table_models = models.group_by(&:table_name)
27
+ table_models.delete_if { |table| table.nil? || !table_exists?(table) }
28
+
29
+ table_models.each do |table, models|
30
+ next if config(:ignore_tables).include?(table)
31
+
32
+ concrete_models = models.reject do |model|
33
+ model.abstract_class? || sti_base_model?(model)
34
+ end
35
+
36
+ connection.columns(table).each do |column|
37
+ next if config(:ignore_columns).include?("#{table}.#{column.name}")
38
+ next if !column.null
39
+ next if !concrete_models.all? { |model| non_null_needed?(model, column) }
40
+
41
+ problem!(column: column.name, table: table)
42
+ end
43
+ end
44
+ end
45
+
46
+ def sti_base_model?(model)
47
+ model.base_class == model &&
48
+ model.columns_hash.include?(model.inheritance_column.to_s)
49
+ end
50
+
51
+ def non_null_needed?(model, column)
52
+ # A foreign key can be validates via the column name (e.g. company_id)
53
+ # or the association name (e.g. company). We collect the allowed names
54
+ # in an array to check for their presence in the validator definition
55
+ # in one go.
56
+ attribute_name_forms = [column.name.to_sym]
57
+ belongs_to = model.reflect_on_all_associations(:belongs_to).find do |reflection|
58
+ reflection.foreign_key == column.name
59
+ end
60
+ attribute_name_forms << belongs_to.name.to_sym if belongs_to
61
+
62
+ model.validators.any? do |validator|
63
+ validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
64
+ (validator.attributes & attribute_name_forms).present? &&
65
+ !validator.options[:allow_nil] &&
66
+ !validator.options[:if] &&
67
+ !validator.options[:unless]
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,30 +1,44 @@
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
5
- class MissingPresenceValidation < Base
6
- def run
7
- eager_load!
6
+ module Detectors
7
+ class MissingPresenceValidation < Base # :nodoc:
8
+ @description = "detect non-NULL columns without a corresponding presence validator"
9
+ @config = {
10
+ ignore_models: {
11
+ description: "models whose underlying tables' columns should not be checked",
12
+ global: true
13
+ },
14
+ ignore_attributes: {
15
+ description: "specific attributes, written as Model.attribute, that should not be checked"
16
+ }
17
+ }
18
+
19
+ private
8
20
 
9
- success(hash_from_pairs(models.reject do |model|
10
- model.table_name.nil? || model.table_name == 'schema_migrations'
11
- end.map do |model|
12
- [
13
- model.name,
14
- connection.columns(model.table_name).select do |column|
15
- validator_needed?(model, column) &&
16
- !validator_present?(model, column)
17
- end.map(&:name)
18
- ]
19
- end.reject do |model_name, columns|
20
- columns.empty?
21
- end))
21
+ def message(column:, model:)
22
+ "add a `presence` validator to #{model}.#{column} - it's NOT NULL but lacks a validator"
22
23
  end
23
24
 
24
- private
25
+ def detect
26
+ models(except: config(:ignore_models)).each do |model|
27
+ next if model.table_name.nil?
28
+ next unless table_exists?(model.table_name)
29
+
30
+ connection.columns(model.table_name).each do |column|
31
+ next unless validator_needed?(model, column)
32
+ next if validator_present?(model, column)
33
+ next if config(:ignore_attributes).include?("#{model}.#{column.name}")
34
+
35
+ problem!(column: column.name, model: model.name)
36
+ end
37
+ end
38
+ end
25
39
 
26
40
  def validator_needed?(model, column)
27
- ![model.primary_key, 'created_at', 'updated_at'].include?(column.name) &&
41
+ ![model.primary_key, "created_at", "updated_at"].include?(column.name) &&
28
42
  !column.null
29
43
  end
30
44
 
@@ -49,7 +63,7 @@ module ActiveRecordDoctor
49
63
  model.validators.any? do |validator|
50
64
  validator.is_a?(ActiveModel::Validations::ExclusionValidator) &&
51
65
  validator.attributes.include?(column.name.to_sym) &&
52
- validator.options.fetch(:in, []).include?(nil)
66
+ validator.options.fetch(:in, []).include?(nil)
53
67
  end
54
68
  end
55
69