active_record_doctor 1.8.0 → 1.10.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 (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
@@ -4,55 +4,110 @@ require "active_record_doctor/detectors/base"
4
4
 
5
5
  module ActiveRecordDoctor
6
6
  module Detectors
7
- # Find has_many/has_one associations with dependent options not taking the
8
- # related model's callbacks into account.
9
- class IncorrectDependentOption < Base
10
- # rubocop:disable Layout/LineLength
11
- @description = "Detect associations that should use a different dependent option based on callbacks on the related model"
12
- # rubocop:enable Layout/LineLength
13
-
14
- def run
15
- eager_load!
16
-
17
- problems(hash_from_pairs(models.reject do |model|
18
- model.table_name.nil?
19
- end.map do |model|
20
- [
21
- model.name,
22
- associations_with_incorrect_dependent_options(model)
23
- ]
24
- end.reject do |_model_name, associations|
25
- associations.empty?
26
- end))
27
- end
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
+ }
28
18
 
29
19
  private
30
20
 
31
- def associations_with_incorrect_dependent_options(model)
32
- reflections = model.reflect_on_all_associations(:has_many) + model.reflect_on_all_associations(:has_one)
33
- reflections.map do |reflection|
34
- if callback_action(reflection) == :invoke && !defines_destroy_callbacks?(reflection.klass)
35
- suggestion =
36
- case reflection.macro
37
- when :has_many then :suggest_delete_all
38
- when :has_one then :suggest_delete
39
- else raise("unsupported association type #{reflection.macro}")
21
+ def message(model:, association:, problem:, associated_models:)
22
+ associated_models.sort!
23
+
24
+ models_part =
25
+ if associated_models.length == 1
26
+ "model #{associated_models[0]} has"
27
+ else
28
+ "models #{associated_models.join(', ')} have"
29
+ end
30
+
31
+ # rubocop:disable Layout/LineLength
32
+ case problem
33
+ when :suggest_destroy
34
+ "use `dependent: :destroy` or similar on #{model}.#{association} - the associated #{models_part} callbacks that are currently skipped"
35
+ when :suggest_delete
36
+ "use `dependent: :delete` or similar on #{model}.#{association} - the associated #{models_part} no callbacks and can be deleted without loading"
37
+ when :suggest_delete_all
38
+ "use `dependent: :delete_all` or similar on #{model}.#{association} - associated #{models_part} no validations and can be deleted in bulk"
39
+ end
40
+ # rubocop:enable Layout/LineLength
41
+ end
42
+
43
+ def detect
44
+ models(except: config(:ignore_models)).each do |model|
45
+ next unless model.table_exists?
46
+
47
+ associations = model.reflect_on_all_associations(:has_many) +
48
+ model.reflect_on_all_associations(:has_one) +
49
+ model.reflect_on_all_associations(:belongs_to)
50
+
51
+ associations.each do |association|
52
+ next if config(:ignore_associations).include?("#{model.name}.#{association.name}")
53
+
54
+ associated_models =
55
+ if association.polymorphic?
56
+ models_having(as: association.name)
57
+ else
58
+ [association.klass]
40
59
  end
41
60
 
42
- [reflection.name, suggestion]
43
- elsif callback_action(reflection) == :skip && defines_destroy_callbacks?(reflection.klass)
44
- [reflection.name, :suggest_destroy]
61
+ deletable_models, destroyable_models = associated_models.partition { |klass| deletable?(klass) }
62
+
63
+ if callback_action(association) == :invoke && destroyable_models.empty? && deletable_models.present?
64
+ suggestion =
65
+ case association.macro
66
+ when :has_many then :suggest_delete_all
67
+ when :has_one, :belongs_to then :suggest_delete
68
+ else raise("unsupported association type #{association.macro}")
69
+ end
70
+
71
+ problem!(model: model.name, association: association.name, problem: suggestion,
72
+ associated_models: deletable_models.map(&:name))
73
+ elsif callback_action(association) == :skip && destroyable_models.present?
74
+ problem!(model: model.name, association: association.name, problem: :suggest_destroy,
75
+ associated_models: destroyable_models.map(&:name))
76
+ end
45
77
  end
46
- end.compact
78
+ end
79
+ end
80
+
81
+ def models_having(as:)
82
+ models.select do |model|
83
+ associations = model.reflect_on_all_associations(:has_one) +
84
+ model.reflect_on_all_associations(:has_many)
85
+
86
+ associations.any? do |association|
87
+ association.options[:as] == as
88
+ end
89
+ end
47
90
  end
48
91
 
49
92
  def callback_action(reflection)
50
93
  case reflection.options[:dependent]
51
- when :delete_all then :skip
94
+ when :delete, :delete_all then :skip
52
95
  when :destroy then :invoke
53
96
  end
54
97
  end
55
98
 
99
+ def deletable?(model)
100
+ !defines_destroy_callbacks?(model) &&
101
+ dependent_models(model).all? do |dependent_model|
102
+ foreign_key = foreign_key(dependent_model.table_name, model.table_name)
103
+
104
+ foreign_key.nil? ||
105
+ foreign_key.on_delete == :nullify || (
106
+ foreign_key.on_delete == :cascade && deletable?(dependent_model)
107
+ )
108
+ end
109
+ end
110
+
56
111
  def defines_destroy_callbacks?(model)
57
112
  # Destroying an associated model involves loading it first hence
58
113
  # initialize and find are present. If they are defined on the model
@@ -66,6 +121,18 @@ module ActiveRecordDoctor
66
121
  model._commit_callbacks.present? ||
67
122
  model._rollback_callbacks.present?
68
123
  end
124
+
125
+ def dependent_models(model)
126
+ reflections = model.reflect_on_all_associations(:has_many) +
127
+ model.reflect_on_all_associations(:has_one)
128
+ reflections.map(&:klass)
129
+ end
130
+
131
+ def foreign_key(from_table, to_table)
132
+ connection.foreign_keys(from_table).find do |foreign_key|
133
+ foreign_key.to_table == to_table
134
+ end
135
+ end
69
136
  end
70
137
  end
71
138
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ class IncorrectLengthValidation < Base # :nodoc:
8
+ @description = "detect mismatches between database length limits and model length validations"
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:, table:, database_maximum:, model_maximum:)
22
+ # rubocop:disable Layout/LineLength
23
+ if database_maximum && model_maximum
24
+ "the schema limits #{table}.#{attribute} to #{database_maximum} characters but the length validator on #{model}.#{attribute} enforces a maximum of #{model_maximum} characters - set both limits to the same value or remove both"
25
+ elsif database_maximum && model_maximum.nil?
26
+ "the schema limits #{table}.#{attribute} to #{database_maximum} characters but there's no length validator on #{model}.#{attribute} - remove the database limit or add the validator"
27
+ elsif database_maximum.nil? && model_maximum
28
+ "the length validator on #{model}.#{attribute} enforces a maximum of #{model_maximum} characters but there's no schema limit on #{table}.#{attribute} - remove the validator or the schema length limit"
29
+ end
30
+ # rubocop:enable Layout/LineLength
31
+ end
32
+
33
+ def detect
34
+ models(except: config(:ignore_models)).each do |model|
35
+ next unless model.table_exists?
36
+
37
+ connection.columns(model.table_name).each do |column|
38
+ next if config(:ignore_attributes).include?("#{model.name}.#{column.name}")
39
+ next if ![:string, :text].include?(column.type)
40
+
41
+ model_maximum = maximum_allowed_by_validations(model)
42
+ next if model_maximum == column.limit
43
+
44
+ problem!(
45
+ model: model.name,
46
+ attribute: column.name,
47
+ table: model.table_name,
48
+ database_maximum: column.limit,
49
+ model_maximum: model_maximum
50
+ )
51
+ end
52
+ end
53
+ end
54
+
55
+ def maximum_allowed_by_validations(model)
56
+ length_validator = model.validators.find do |validator|
57
+ validator.kind == :length && validator.options.include?(:maximum)
58
+ end
59
+ length_validator ? length_validator.options[:maximum] : nil
60
+ end
61
+ end
62
+ end
63
+ 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
@@ -4,32 +4,41 @@ require "active_record_doctor/detectors/base"
4
4
 
5
5
  module ActiveRecordDoctor
6
6
  module Detectors
7
- # Find foreign-key like columns lacking an actual foreign key constraint.
8
- class MissingForeignKeys < Base
9
- @description = "Detect association columns without a foreign key constraint"
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
- # We need to skip polymorphic associations as they can reference
19
- # multiple tables but a foreign key constraint can reference
20
- # a single predefined table.
21
- named_like_foreign_key?(column) &&
22
- !foreign_key?(table, column) &&
23
- !polymorphic_foreign_key?(table, column)
24
- end.map(&:name)
25
- ]
26
- end.reject do |_table, columns|
27
- columns.empty?
28
- end))
29
- end
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
+ }
30
18
 
31
19
  private
32
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
+
33
42
  def named_like_foreign_key?(column)
34
43
  column.name.end_with?("_id")
35
44
  end
@@ -4,39 +4,52 @@ 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, models| !models.first.table_exists? }
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
+ next if not_null_check_constraint_exists?(table, column)
41
+
42
+ problem!(column: column.name, table: table)
43
+ end
44
+ end
45
+ end
46
+
47
+ def sti_base_model?(model)
48
+ model.base_class == model &&
49
+ model.columns_hash.include?(model.inheritance_column.to_s)
37
50
  end
38
51
 
39
- def has_mandatory_presence_validator?(model, column)
52
+ def non_null_needed?(model, column)
40
53
  # A foreign key can be validates via the column name (e.g. company_id)
41
54
  # or the association name (e.g. company). We collect the allowed names
42
55
  # in an array to check for their presence in the validator definition
@@ -4,35 +4,41 @@ 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 unless model.table_exists?
28
+
29
+ connection.columns(model.table_name).each do |column|
30
+ next unless validator_needed?(model, column)
31
+ next if validator_present?(model, column)
32
+ next if config(:ignore_attributes).include?("#{model}.#{column.name}")
33
+
34
+ problem!(column: column.name, model: model.name)
35
+ end
36
+ end
37
+ end
32
38
 
33
39
  def validator_needed?(model, column)
34
- ![model.primary_key, "created_at", "updated_at"].include?(column.name) &&
35
- !column.null
40
+ ![model.primary_key, "created_at", "updated_at", "created_on", "updated_on"].include?(column.name) &&
41
+ (!column.null || not_null_check_constraint_exists?(model.table_name, column))
36
42
  end
37
43
 
38
44
  def validator_present?(model, column)
@@ -4,38 +4,77 @@ 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)
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
+ }
18
+
19
+ private
20
+
21
+ # rubocop:disable Layout/LineLength
22
+ def message(model:, table:, columns:, problem:)
23
+ case problem
24
+ when :validations
25
+ "add a unique index on #{table}(#{columns.join(', ')}) - validating uniqueness in the model without an index can lead to duplicates"
26
+ when :has_ones
27
+ "add a unique index on #{table}(#{columns.first}) - using `has_one` in the #{model.name} model without an index can lead to duplicates"
28
+ end
29
+ end
30
+ # rubocop:enable Layout/LineLength
31
+
32
+ def detect
33
+ validations_without_indexes
34
+ has_ones_without_indexes
35
+ end
36
+
37
+ def validations_without_indexes
38
+ models(except: config(:ignore_models)).each do |model|
39
+ next unless model.table_exists?
40
+
41
+ model.validators.each do |validator|
42
+ scope = Array(validator.options.fetch(:scope, []))
43
+
44
+ next unless validator.is_a?(ActiveRecord::Validations::UniquenessValidator)
45
+ next unless supported_validator?(validator)
46
+
47
+ validator.attributes.each do |attribute|
48
+ columns = resolve_attributes(model, scope + [attribute])
49
+
50
+ next if unique_index?(model.table_name, columns)
51
+ next if ignore_columns.include?("#{model.name}(#{columns.join(',')})")
52
+
53
+ problem!(model: model, table: model.table_name, columns: columns, problem: :validations)
31
54
  end
32
- ]
33
- end.reject do |_table_name, indexes|
34
- indexes.empty?
35
- end))
55
+ end
56
+ end
36
57
  end
37
58
 
38
- private
59
+ def has_ones_without_indexes # rubocop:disable Naming/PredicateName
60
+ models.each do |model|
61
+ has_ones = model.reflect_on_all_associations(:has_one)
62
+ has_ones.each do |has_one|
63
+ next if has_one.is_a?(ActiveRecord::Reflection::ThroughReflection) || has_one.scope
64
+
65
+ association_model = has_one.klass
66
+ next if config(:ignore_models).include?(association_model.name)
67
+
68
+ foreign_key = has_one.foreign_key
69
+ next if ignore_columns.include?(foreign_key.to_s)
70
+
71
+ table_name = association_model.table_name
72
+ next if unique_index?(table_name, [foreign_key])
73
+
74
+ problem!(model: model, table: table_name, columns: [foreign_key], problem: :has_ones)
75
+ end
76
+ end
77
+ end
39
78
 
40
79
  def supported_validator?(validator)
41
80
  validator.options[:if].nil? &&
@@ -49,11 +88,32 @@ module ActiveRecordDoctor
49
88
  validator.options.fetch(:case_sensitive, true)
50
89
  end
51
90
 
52
- def unique_index?(table_name, columns, scope)
53
- columns = (Array(scope) + columns).map(&:to_s)
91
+ def resolve_attributes(model, attributes)
92
+ attributes.flat_map do |attribute|
93
+ reflection = model.reflect_on_association(attribute)
94
+
95
+ if reflection.nil?
96
+ attribute
97
+ elsif reflection.polymorphic?
98
+ [reflection.foreign_type, reflection.foreign_key]
99
+ else
100
+ reflection.foreign_key
101
+ end
102
+ end.map(&:to_s)
103
+ end
54
104
 
105
+ def unique_index?(table_name, columns, scope = nil)
106
+ columns = (Array(scope) + columns).map(&:to_s)
55
107
  indexes(table_name).any? do |index|
56
- index.columns.to_set == columns.to_set && index.unique
108
+ index.unique &&
109
+ index.where.nil? &&
110
+ (Array(index.columns) - columns).empty?
111
+ end
112
+ end
113
+
114
+ def ignore_columns
115
+ @ignore_columns ||= config(:ignore_columns).map do |column|
116
+ column.gsub(" ", "")
57
117
  end
58
118
  end
59
119
  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 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) || uuid?(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
+
40
+ def uuid?(column)
41
+ column.sql_type == "uuid"
42
+ end
43
+ end
44
+ end
45
+ end