active_record_doctor 1.8.0 → 1.9.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +246 -48
  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 +110 -19
  7. data/lib/active_record_doctor/detectors/extraneous_indexes.rb +63 -37
  8. data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +32 -23
  9. data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +70 -34
  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 +32 -23
  12. data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +40 -28
  13. data/lib/active_record_doctor/detectors/missing_presence_validation.rb +28 -21
  14. data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +40 -30
  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 +19 -20
  17. data/lib/active_record_doctor/detectors/unindexed_deleted_at.rb +44 -18
  18. data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +31 -20
  19. data/lib/active_record_doctor/detectors.rb +12 -4
  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/rake/task.rb +78 -0
  23. data/lib/active_record_doctor/runner.rb +41 -0
  24. data/lib/active_record_doctor/version.rb +1 -1
  25. data/lib/active_record_doctor.rb +7 -3
  26. data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +34 -21
  27. data/lib/tasks/active_record_doctor.rake +9 -18
  28. data/test/active_record_doctor/config/loader_test.rb +120 -0
  29. data/test/active_record_doctor/config_test.rb +116 -0
  30. data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +131 -8
  31. data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +48 -5
  32. data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +190 -12
  33. data/test/active_record_doctor/detectors/mismatched_foreign_key_type_test.rb +82 -0
  34. data/test/active_record_doctor/detectors/missing_foreign_keys_test.rb +50 -4
  35. data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +138 -24
  36. data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +74 -13
  37. data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +57 -8
  38. data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +64 -0
  39. data/test/active_record_doctor/detectors/undefined_table_references_test.rb +34 -21
  40. data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +112 -8
  41. data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +56 -4
  42. data/test/active_record_doctor/runner_test.rb +42 -0
  43. data/test/generators/active_record_doctor/add_indexes/add_indexes_generator_test.rb +131 -0
  44. data/test/model_factory.rb +73 -23
  45. data/test/setup.rb +62 -72
  46. metadata +40 -9
  47. data/lib/active_record_doctor/printers/io_printer.rb +0 -133
  48. data/lib/active_record_doctor/task.rb +0 -28
  49. data/test/active_record_doctor/printers/io_printer_test.rb +0 -33
@@ -4,39 +4,51 @@ require "active_record_doctor/detectors/base"
4
4
 
5
5
  module ActiveRecordDoctor
6
6
  module Detectors
7
- # Detect model-level presence validators on columns that lack a non-NULL constraint thus allowing potentially
8
- # invalid insertions.
9
- class MissingNonNullConstraint < Base
10
- @description = "Detect presence validators not backed by a non-NULL constraint"
11
-
12
- def run
13
- eager_load!
14
-
15
- problems(hash_from_pairs(models.reject do |model|
16
- model.table_name.nil? ||
17
- model.table_name == "schema_migrations" ||
18
- !table_exists?(model.table_name)
19
- end.map do |model|
20
- [
21
- model.table_name,
22
- connection.columns(model.table_name).select do |column|
23
- validator_needed?(model, column) &&
24
- has_mandatory_presence_validator?(model, column) &&
25
- column.null
26
- end.map(&:name)
27
- ]
28
- end.reject do |_model_name, columns|
29
- columns.empty?
30
- end))
31
- end
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
+ }
32
18
 
33
19
  private
34
20
 
35
- def validator_needed?(model, column)
36
- ![model.primary_key, "created_at", "updated_at"].include?(column.name)
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)
37
49
  end
38
50
 
39
- def has_mandatory_presence_validator?(model, column)
51
+ def non_null_needed?(model, column)
40
52
  # A foreign key can be validates via the column name (e.g. company_id)
41
53
  # or the association name (e.g. company). We collect the allowed names
42
54
  # in an array to check for their presence in the validator definition
@@ -4,31 +4,38 @@ require "active_record_doctor/detectors/base"
4
4
 
5
5
  module ActiveRecordDoctor
6
6
  module Detectors
7
- # Detect models with non-NULL columns that lack the corresponding model-level validator.
8
- class MissingPresenceValidation < Base
9
- @description = "Detect non-NULL columns without a presence validator"
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
+ }
10
18
 
11
- def run
12
- eager_load!
19
+ private
13
20
 
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
- validator_needed?(model, column) &&
23
- !validator_present?(model, column)
24
- end.map(&:name)
25
- ]
26
- end.reject do |_model_name, columns|
27
- columns.empty?
28
- end))
21
+ def message(column:, model:)
22
+ "add a `presence` validator to #{model}.#{column} - it's NOT NULL but lacks a validator"
29
23
  end
30
24
 
31
- 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
32
39
 
33
40
  def validator_needed?(model, column)
34
41
  ![model.primary_key, "created_at", "updated_at"].include?(column.name) &&
@@ -4,39 +4,49 @@ require "active_record_doctor/detectors/base"
4
4
 
5
5
  module ActiveRecordDoctor
6
6
  module Detectors
7
- # Detect columns covered by a uniqueness validation that lack the corresponding unique index thus risking duplicate
8
- # inserts.
9
- class MissingUniqueIndexes < Base
10
- @description = "Detect columns covered by a uniqueness validator without a unique index"
11
-
12
- def run
13
- eager_load!
14
-
15
- problems(hash_from_pairs(models.reject do |model|
16
- model.table_name.nil?
17
- end.map do |model|
18
- [
19
- model.table_name,
20
- model.validators.select do |validator|
21
- table_name = model.table_name
22
- scope = validator.options.fetch(:scope, [])
23
-
24
- validator.is_a?(ActiveRecord::Validations::UniquenessValidator) &&
25
- supported_validator?(validator) &&
26
- !unique_index?(table_name, validator.attributes, scope)
27
- end.map do |validator|
28
- scope = Array(validator.options.fetch(:scope, []))
29
- attributes = validator.attributes
30
- (scope + attributes).map(&:to_s)
31
- end
32
- ]
33
- end.reject do |_table_name, indexes|
34
- indexes.empty?
35
- end))
36
- end
7
+ class MissingUniqueIndexes < Base # :nodoc:
8
+ @description = "detect uniqueness validators not backed by a database constraint"
9
+ @config = {
10
+ ignore_models: {
11
+ description: "models whose uniqueness validators should not be checked",
12
+ global: true
13
+ },
14
+ ignore_columns: {
15
+ description: "specific validators, written as Model(column1, column2, ...), that should not be checked"
16
+ }
17
+ }
37
18
 
38
19
  private
39
20
 
21
+ def message(table:, columns:)
22
+ # rubocop:disable Layout/LineLength
23
+ "add a unique index on #{table}(#{columns.join(', ')}) - validating uniqueness in the model without an index can lead to duplicates"
24
+ # rubocop:enable Layout/LineLength
25
+ end
26
+
27
+ def detect
28
+ ignore_columns = config(:ignore_columns).map do |column|
29
+ column.gsub(" ", "")
30
+ end
31
+
32
+ models(except: config(:ignore_models)).each do |model|
33
+ next if model.table_name.nil?
34
+
35
+ model.validators.each do |validator|
36
+ scope = Array(validator.options.fetch(:scope, []))
37
+
38
+ next unless validator.is_a?(ActiveRecord::Validations::UniquenessValidator)
39
+ next unless supported_validator?(validator)
40
+ next if unique_index?(model.table_name, validator.attributes, scope)
41
+
42
+ columns = (scope + validator.attributes).map(&:to_s)
43
+ next if ignore_columns.include?("#{model.name}(#{columns.join(',')})")
44
+
45
+ problem!(table: model.table_name, columns: columns)
46
+ end
47
+ end
48
+ end
49
+
40
50
  def supported_validator?(validator)
41
51
  validator.options[:if].nil? &&
42
52
  validator.options[:unless].nil? &&
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ class ShortPrimaryKeyType < Base # :nodoc:
8
+ @description = "detect primary keys with short integer types"
9
+ @config = {
10
+ ignore_tables: {
11
+ description: "tables whose primary keys should not be checked",
12
+ global: true
13
+ }
14
+ }
15
+
16
+ private
17
+
18
+ def message(table:, column:)
19
+ "change the type of #{table}.#{column} to bigint"
20
+ end
21
+
22
+ def detect
23
+ tables(except: config(:ignore_tables)).each do |table|
24
+ column = primary_key(table)
25
+ next if column.nil?
26
+ next if bigint?(column)
27
+
28
+ problem!(table: table, column: column.name)
29
+ end
30
+ end
31
+
32
+ def bigint?(column)
33
+ if column.respond_to?(:bigint?)
34
+ column.bigint?
35
+ else
36
+ /\Abigint\b/.match?(column.sql_type)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -4,30 +4,29 @@ require "active_record_doctor/detectors/base"
4
4
 
5
5
  module ActiveRecordDoctor
6
6
  module Detectors
7
- # Find models referencing non-existent database tables or views.
8
- class UndefinedTableReferences < Base
9
- @description = "Detect models referencing undefined tables or views"
7
+ class UndefinedTableReferences < Base # :nodoc:
8
+ @description = "detect models referencing undefined tables or views"
9
+ @config = {
10
+ ignore_models: {
11
+ description: "models whose underlying tables should not be checked for existence",
12
+ global: true
13
+ }
14
+ }
10
15
 
11
- def run
12
- eager_load!
16
+ private
13
17
 
14
- # If we can't list views due to old Rails version or unsupported
15
- # database then existing_views is nil. We inform the caller we haven't
16
- # consulted views so that it can display an appropriate warning.
17
- existing_views = views
18
+ def message(model:, table:)
19
+ "#{model} references a non-existent table or view named #{table}"
20
+ end
18
21
 
19
- offending_models = models.select do |model|
20
- model.table_name.present? &&
21
- !tables.include?(model.table_name) &&
22
- (
23
- existing_views.nil? ||
24
- !existing_views.include?(model.table_name)
25
- )
26
- end.map do |model|
27
- [model.name, model.table_name]
28
- end
22
+ def detect
23
+ models(except: config(:ignore_models)).each do |model|
24
+ next if model.table_name.nil?
25
+ next if tables.include?(model.table_name)
26
+ next if tables_and_views.include?(model.table_name)
29
27
 
30
- problems(offending_models, views_checked: !existing_views.nil?)
28
+ problem!(model: model.name, table: model.table_name)
29
+ end
31
30
  end
32
31
  end
33
32
  end
@@ -4,25 +4,51 @@ require "active_record_doctor/detectors/base"
4
4
 
5
5
  module ActiveRecordDoctor
6
6
  module Detectors
7
- # Find unindexed deleted_at columns.
8
- class UnindexedDeletedAt < Base
9
- PATTERN = [
10
- "deleted_at",
11
- "discarded_at"
12
- ].join("|").freeze
13
-
14
- @description = "Detect unindexed deleted_at columns"
15
-
16
- def run
17
- problems(connection.tables.select do |table|
18
- connection.columns(table).any? { |column| column.name =~ /^#{PATTERN}$/ }
19
- end.flat_map do |table|
20
- connection.indexes(table).reject do |index|
21
- index.where =~ /\b#{PATTERN}\s+IS\s+NULL\b/i
22
- end.map do |index|
23
- index.name
7
+ class UnindexedDeletedAt < Base # :nodoc:
8
+ @description = "detect indexes that exclude deletion timestamp columns"
9
+ @config = {
10
+ ignore_tables: {
11
+ description: "tables whose indexes should not be checked",
12
+ global: true
13
+ },
14
+ ignore_columns: {
15
+ description: "specific columns, written as table.column, that should not be reported as unindexed"
16
+ },
17
+ ignore_indexes: {
18
+ description: "specific indexes that should not be reported as excluding a timestamp column"
19
+ },
20
+ column_names: {
21
+ description: "deletion timestamp column names"
22
+ }
23
+ }
24
+
25
+ private
26
+
27
+ def message(index:, column_name:)
28
+ # rubocop:disable Layout/LineLength
29
+ "consider adding `WHERE #{column_name} IS NULL` to #{index} - a partial index can speed lookups of soft-deletable models"
30
+ # rubocop:enable Layout/LineLength
31
+ end
32
+
33
+ def detect
34
+ tables(except: config(:ignore_tables)).each do |table|
35
+ timestamp_columns = connection.columns(table).reject do |column|
36
+ config(:ignore_columns).include?("#{table}.#{column.name}")
37
+ end.select do |column|
38
+ config(:column_names).include?(column.name)
39
+ end
40
+
41
+ next if timestamp_columns.empty?
42
+
43
+ timestamp_columns.each do |timestamp_column|
44
+ indexes(table, except: config(:ignore_indexes)).each do |index|
45
+ # TODO: whole word
46
+ next if index.where =~ /\b#{timestamp_column.name}\s+IS\s+NULL\b/i
47
+
48
+ problem!(index: index.name, column_name: timestamp_column.name)
49
+ end
24
50
  end
25
- end)
51
+ end
26
52
  end
27
53
  end
28
54
  end
@@ -4,29 +4,40 @@ require "active_record_doctor/detectors/base"
4
4
 
5
5
  module ActiveRecordDoctor
6
6
  module Detectors
7
- # Find foreign keys that lack indexes (usually recommended for performance reasons).
8
- class UnindexedForeignKeys < Base
9
- @description = "Detect foreign keys without an index on them"
10
-
11
- def run
12
- problems(hash_from_pairs(tables.reject do |table|
13
- table == "schema_migrations"
14
- end.map do |table|
15
- [
16
- table,
17
- connection.columns(table).select do |column|
18
- foreign_key?(column) &&
19
- !indexed?(table, column) &&
20
- !indexed_as_polymorphic?(table, column)
21
- end.map(&:name)
22
- ]
23
- end.reject do |_table, columns|
24
- columns.empty?
25
- end))
26
- end
7
+ class UnindexedForeignKeys < Base # :nodoc:
8
+ @description = "detect unindexed foreign keys"
9
+ @config = {
10
+ ignore_tables: {
11
+ description: "tables whose foreign keys 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
+ }
27
18
 
28
19
  private
29
20
 
21
+ def message(table:, column:)
22
+ # rubocop:disable Layout/LineLength
23
+ "add an index on #{table}.#{column} - foreign keys are often used in database lookups and should be indexed for performance reasons"
24
+ # rubocop:enable Layout/LineLength
25
+ end
26
+
27
+ def detect
28
+ tables(except: config(:ignore_tables)).each do |table|
29
+ connection.columns(table).each do |column|
30
+ next if config(:ignore_columns).include?("#{table}.#{column.name}")
31
+
32
+ next unless foreign_key?(column)
33
+ next if indexed?(table, column)
34
+ next if indexed_as_polymorphic?(table, column)
35
+
36
+ problem!(table: table, column: column.name)
37
+ end
38
+ end
39
+ end
40
+
30
41
  def foreign_key?(column)
31
42
  column.name.end_with?("_id")
32
43
  end
@@ -3,11 +3,19 @@
3
3
  require "active_support"
4
4
  require "active_support/core_ext/class/subclasses"
5
5
 
6
- module ActiveRecordDoctor
6
+ module ActiveRecordDoctor # :nodoc:
7
+ def self.detectors
8
+ @detectors ||=
9
+ begin
10
+ detectors = {}
11
+ ActiveRecordDoctor::Detectors::Base.subclasses.each do |detector|
12
+ detectors[detector.underscored_name] = detector
13
+ end
14
+ detectors
15
+ end
16
+ end
17
+
7
18
  # Container module for all detectors, implemented as separate classes.
8
19
  module Detectors
9
- def self.all
10
- ActiveRecordDoctor::Detectors::Base.subclasses
11
- end
12
20
  end
13
21
  end