database_consistency 2.0.4 → 3.0.5

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/bin/database_consistency +19 -2
  3. data/lib/database_consistency/checkers/association_checkers/association_checker.rb +11 -0
  4. data/lib/database_consistency/checkers/association_checkers/foreign_key_cascade_checker.rb +5 -4
  5. data/lib/database_consistency/checkers/association_checkers/foreign_key_checker.rb +1 -1
  6. data/lib/database_consistency/checkers/association_checkers/foreign_key_type_checker.rb +46 -35
  7. data/lib/database_consistency/checkers/association_checkers/missing_dependent_destroy_checker.rb +53 -0
  8. data/lib/database_consistency/checkers/base_checker.rb +1 -2
  9. data/lib/database_consistency/checkers/column_checkers/column_checker.rb +4 -0
  10. data/lib/database_consistency/checkers/column_checkers/missing_index_find_by_checker.rb +192 -0
  11. data/lib/database_consistency/checkers/column_checkers/null_constraint_checker.rb +1 -1
  12. data/lib/database_consistency/checkers/enum_checkers/enum_checker.rb +4 -0
  13. data/lib/database_consistency/checkers/index_checkers/index_checker.rb +4 -0
  14. data/lib/database_consistency/checkers/index_checkers/redundant_index_checker.rb +2 -2
  15. data/lib/database_consistency/checkers/index_checkers/unique_index_checker.rb +7 -4
  16. data/lib/database_consistency/checkers/model_checkers/model_checker.rb +4 -0
  17. data/lib/database_consistency/checkers/model_checkers/view_primary_key_checker.rb +36 -0
  18. data/lib/database_consistency/checkers/validator_checkers/missing_unique_index_checker.rb +16 -4
  19. data/lib/database_consistency/checkers/validator_checkers/validator_checker.rb +4 -0
  20. data/lib/database_consistency/checkers/validators_fraction_checkers/validators_fraction_checker.rb +4 -0
  21. data/lib/database_consistency/configuration.rb +8 -7
  22. data/lib/database_consistency/debug_context.rb +0 -1
  23. data/lib/database_consistency/files_helper.rb +53 -0
  24. data/lib/database_consistency/helper.rb +215 -2
  25. data/lib/database_consistency/prism_helper.rb +38 -0
  26. data/lib/database_consistency/rescue_error.rb +3 -3
  27. data/lib/database_consistency/version.rb +1 -1
  28. data/lib/database_consistency/writers/autofix/migration_base.rb +1 -1
  29. data/lib/database_consistency/writers/autofix_writer.rb +32 -1
  30. data/lib/database_consistency/writers/base_writer.rb +5 -4
  31. data/lib/database_consistency/writers/simple/association_foreign_type_missing_null_constraint.rb +1 -1
  32. data/lib/database_consistency/writers/simple/association_missing_index.rb +1 -1
  33. data/lib/database_consistency/writers/simple/association_missing_null_constraint.rb +1 -1
  34. data/lib/database_consistency/writers/simple/enum_values_inconsistent_with_ar_enum.rb +1 -1
  35. data/lib/database_consistency/writers/simple/enum_values_inconsistent_with_inclusion.rb +1 -1
  36. data/lib/database_consistency/writers/simple/has_one_missing_unique_index.rb +1 -1
  37. data/lib/database_consistency/writers/simple/implicit_order_column_missing.rb +1 -1
  38. data/lib/database_consistency/writers/simple/inconsistent_enum_type.rb +1 -1
  39. data/lib/database_consistency/writers/simple/inconsistent_types.rb +2 -2
  40. data/lib/database_consistency/writers/simple/length_validator_greater_limit.rb +1 -1
  41. data/lib/database_consistency/writers/simple/length_validator_lower_limit.rb +1 -1
  42. data/lib/database_consistency/writers/simple/length_validator_missing.rb +1 -1
  43. data/lib/database_consistency/writers/simple/missing_association_class.rb +1 -1
  44. data/lib/database_consistency/writers/simple/missing_dependent_destroy.rb +22 -0
  45. data/lib/database_consistency/writers/simple/missing_foreign_key.rb +1 -1
  46. data/lib/database_consistency/writers/simple/missing_foreign_key_cascade.rb +1 -1
  47. data/lib/database_consistency/writers/simple/missing_index_find_by.rb +32 -0
  48. data/lib/database_consistency/writers/simple/missing_table.rb +1 -1
  49. data/lib/database_consistency/writers/simple/missing_unique_index.rb +1 -1
  50. data/lib/database_consistency/writers/simple/missing_uniqueness_validation.rb +1 -1
  51. data/lib/database_consistency/writers/simple/null_constraint_association_misses_validator.rb +1 -1
  52. data/lib/database_consistency/writers/simple/null_constraint_misses_validator.rb +1 -1
  53. data/lib/database_consistency/writers/simple/null_constraint_missing.rb +1 -1
  54. data/lib/database_consistency/writers/simple/possible_null.rb +1 -1
  55. data/lib/database_consistency/writers/simple/redundant_case_insensitive_option.rb +1 -1
  56. data/lib/database_consistency/writers/simple/redundant_index.rb +1 -1
  57. data/lib/database_consistency/writers/simple/redundant_unique_index.rb +1 -1
  58. data/lib/database_consistency/writers/simple/small_primary_key.rb +1 -1
  59. data/lib/database_consistency/writers/simple/three_state_boolean.rb +1 -1
  60. data/lib/database_consistency/writers/simple/view_missing_primary_key.rb +21 -0
  61. data/lib/database_consistency/writers/simple/view_primary_key_column_missing.rb +21 -0
  62. data/lib/database_consistency.rb +14 -7
  63. metadata +13 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60ddbdae671fab802b24773279d1f669f2ebe5303e7836f8ea73aebbabd34386
4
- data.tar.gz: 1bb99a2ac677ff177d33c8ff38b052899d8da6dbb8eed842dcd2b6ce0a24a763
3
+ metadata.gz: f88fd56f01f639d5614fa82f5e76f6d4a4dfb05c04c246f5baeedf93fd01fe24
4
+ data.tar.gz: 0bb41479e542c995569b54a6cfc188ea39103c30a074560f0bae15f8223c1128
5
5
  SHA512:
6
- metadata.gz: f0121b1c39050c29c03b018d30ea28e7eed0d80a5a5e1b13dedfd985a93cb1b4a70e879e0bb1cfb816e6ddeffce0a9b1afa74f95845ce6c9c192a2ef0393d6de
7
- data.tar.gz: 6c891974af21502183d550dee005ee5c9266033261285fb4e21809beccb17e7f38cecdcbb3038d3336f41fd1c1ab7950d9a19bf603c9dd07b282a56cf37c4696
6
+ metadata.gz: 54baea922639aec00a1c1edb9aaef5e0d70ec91a2ce63552fcb2ab502b93c1a2779c3227fdb55c7cb03afd17a346624cfc192d84c07480583b44bba00554eea5
7
+ data.tar.gz: 69a60f5b359e2bfc89765756115e510646d6167fd2819da7f25451302c52f1f54c2c590e1a487eb2291c9e747a54e80bf4e193b9db506bc13f8cc7a580b0be8c
@@ -39,10 +39,17 @@ opt_parser = OptionParser.new do |opts|
39
39
  options[:todo] = true
40
40
  end
41
41
 
42
- opts.on('-f', '--autofix', 'Automatically fixes issues by adjusting the code or generating missing migrations.') do
42
+ opts.on('-f', '--autofix',
43
+ 'Automatically fixes issues by adjusting the code or generating missing migrations.') do
43
44
  options[:autofix] = true
44
45
  end
45
46
 
47
+ opts.on('--only-checkers=LIST', Array,
48
+ 'When used with --autofix, restricts the fix to offenses produced by the listed checker ' \
49
+ 'class names (comma-separated, e.g. ColumnPresenceChecker,NullConstraintChecker).') do |checkers|
50
+ options[:only_checkers] = checkers
51
+ end
52
+
46
53
  opts.on('-h', '--help', 'Prints this help.') do
47
54
  puts opts
48
55
  exit
@@ -51,6 +58,11 @@ end
51
58
 
52
59
  opt_parser.parse!
53
60
 
61
+ if options[:only_checkers] && !options[:autofix]
62
+ warn '--only-checkers requires --autofix'
63
+ exit 1
64
+ end
65
+
54
66
  base_dir = File.join(Dir.pwd, ARGV.first.to_s)
55
67
  unless File.realpath(base_dir).start_with?(Dir.pwd)
56
68
  puts "\nWarning! You are going out of current directory, ruby version may be wrong and some gems may be missing.\n"
@@ -75,5 +87,10 @@ $LOAD_PATH.unshift(File.expand_path('lib', __dir__))
75
87
  require 'database_consistency'
76
88
 
77
89
  # Process checks
78
- code = DatabaseConsistency.run(config, **options)
90
+ begin
91
+ code = DatabaseConsistency.run(config, **options)
92
+ rescue DatabaseConsistency::Writers::AutofixWriter::UnknownCheckerError => e
93
+ warn e.message
94
+ exit 1
95
+ end
79
96
  exit code
@@ -6,6 +6,10 @@ module DatabaseConsistency
6
6
  class AssociationChecker < BaseChecker
7
7
  attr_reader :model, :association
8
8
 
9
+ def self.processor
10
+ Processors::AssociationsProcessor
11
+ end
12
+
9
13
  def initialize(model, association)
10
14
  super()
11
15
  @model = model
@@ -19,6 +23,13 @@ module DatabaseConsistency
19
23
  def table_or_model_name
20
24
  @table_or_model_name ||= model.name.to_s
21
25
  end
26
+
27
+ def foreign_key # rubocop:disable Metrics/AbcSize
28
+ model.connection.foreign_keys(model.table_name).find do |foreign_key|
29
+ (Helper.extract_columns(association.foreign_key) - Array.wrap(foreign_key.column)).empty? &&
30
+ foreign_key.to_table == association.klass.table_name
31
+ end
32
+ end
22
33
  end
23
34
  end
24
35
  end
@@ -28,6 +28,7 @@ module DatabaseConsistency
28
28
  def preconditions
29
29
  !association.polymorphic? &&
30
30
  !association.belongs_to? &&
31
+ association.association_primary_key.present? &&
31
32
  foreign_key &&
32
33
  DEPENDENT_OPTIONS.include?(dependent_option)
33
34
  rescue StandardError
@@ -68,18 +69,18 @@ module DatabaseConsistency
68
69
  association.klass
69
70
  .connection
70
71
  .foreign_keys(association.klass.table_name)
71
- .find { |fk| fk.column == association.foreign_key.to_s }
72
+ .find { |fk| (Helper.extract_columns(association.foreign_key) - Array.wrap(fk.column)).empty? }
72
73
  end
73
74
 
74
- def report_template(status, error_slug: nil)
75
+ def report_template(status, error_slug: nil) # rubocop:disable Metrics/AbcSize
75
76
  Report.new(
76
77
  status: status,
77
78
  error_message: nil,
78
79
  error_slug: error_slug,
79
80
  primary_table: association.table_name.to_s,
80
- primary_key: association.association_primary_key.to_s,
81
+ primary_key: Helper.extract_columns(association.association_primary_key).join('+'),
81
82
  foreign_table: association.active_record.table_name.to_s,
82
- foreign_key: association.foreign_key.to_s,
83
+ foreign_key: Helper.extract_columns(association.foreign_key).join('+'),
83
84
  cascade_option: required_foreign_key_cascade,
84
85
  **report_attributes
85
86
  )
@@ -43,7 +43,7 @@ module DatabaseConsistency
43
43
  # | persisted | ok |
44
44
  # | missing | fail |
45
45
  def check
46
- if model.connection.foreign_keys(model.table_name).find { |fk| fk.column == association.foreign_key.to_s }
46
+ if foreign_key
47
47
  report_template(:ok)
48
48
  else
49
49
  report_template(:fail, error_slug: :missing_foreign_key)
@@ -3,7 +3,7 @@
3
3
  module DatabaseConsistency
4
4
  module Checkers
5
5
  # This class checks if association's foreign key type covers associated model's primary key (same or bigger)
6
- class ForeignKeyTypeChecker < AssociationChecker
6
+ class ForeignKeyTypeChecker < AssociationChecker # rubocop:disable Metrics/ClassLength
7
7
  Report = ReportBuilder.define(
8
8
  DatabaseConsistency::Report,
9
9
  :pk_name,
@@ -36,10 +36,11 @@ module DatabaseConsistency
36
36
  # | ------------- | ------ |
37
37
  # | covers | ok |
38
38
  # | doesn't cover | fail |
39
- def check # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
40
- if converted_type(associated_column).cover?(converted_type(primary_column))
41
- report_template(:ok)
42
- elsif !(converted_type(associated_column).numeric? && converted_type(primary_column).numeric?)
39
+ def check # rubocop:disable Metrics/MethodLength
40
+ associated_columns_converted_types = associated_columns.map { |column| converted_type(column) }
41
+ primary_columns_converted_types = primary_columns.map { |primary_column| converted_type(primary_column) }
42
+
43
+ if covers?(associated_columns_converted_types, primary_columns_converted_types)
43
44
  report_template(:ok)
44
45
  else
45
46
  report_template(:fail, error_slug: :inconsistent_types)
@@ -53,17 +54,17 @@ module DatabaseConsistency
53
54
  )
54
55
  end
55
56
 
56
- def report_template(status, error_slug: nil) # rubocop:disable Metrics/MethodLength
57
+ def report_template(status, error_slug: nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
57
58
  Report.new(
58
59
  status: status,
59
60
  error_slug: error_slug,
60
61
  error_message: nil,
61
62
  table_to_change: table_to_change,
62
- type_to_set: converted_type(primary_column).convert,
63
- fk_type: converted_type(associated_column).type,
64
- fk_name: associated_key,
65
- pk_type: converted_type(primary_column).type,
66
- pk_name: primary_key,
63
+ type_to_set: primary_columns.map { |primary_column| converted_type(primary_column).convert },
64
+ fk_type: associated_columns.map { |column| converted_type(column).type }.join('+'),
65
+ fk_name: associated_keys.join('+'),
66
+ pk_type: primary_columns.map { |primary_column| converted_type(primary_column).type }.join('+'),
67
+ pk_name: primary_keys.join('+'),
67
68
  **report_attributes
68
69
  )
69
70
  end
@@ -76,36 +77,40 @@ module DatabaseConsistency
76
77
  end
77
78
  end
78
79
 
79
- # @return [String]
80
- def primary_key
81
- @primary_key ||= if belongs_to_association?
82
- association.association_primary_key
83
- else
84
- association.active_record_primary_key
85
- end.to_s
80
+ # @return [Array<String>]
81
+ def primary_keys
82
+ @primary_keys ||= if belongs_to_association?
83
+ Helper.extract_columns(association.association_primary_key)
84
+ else
85
+ Helper.extract_columns(association.active_record_primary_key)
86
+ end
86
87
  end
87
88
 
88
- # @return [String]
89
- def associated_key
90
- @associated_key ||= association.foreign_key.to_s
89
+ # @return [Array<String>]
90
+ def associated_keys
91
+ @associated_keys ||= Helper.extract_columns(association.foreign_key)
91
92
  end
92
93
 
93
- # @return [ActiveRecord::ConnectionAdapters::Column]
94
- def primary_column
95
- @primary_column ||= if belongs_to_association?
96
- column(association.klass, primary_key)
97
- else
98
- column(association.active_record, primary_key)
99
- end
94
+ # @return [Array<ActiveRecord::ConnectionAdapters::Column>]
95
+ def primary_columns
96
+ @primary_columns ||= primary_keys.map do |primary_key|
97
+ if belongs_to_association?
98
+ column(association.klass, primary_key)
99
+ else
100
+ column(association.active_record, primary_key)
101
+ end
102
+ end
100
103
  end
101
104
 
102
- # @return [ActiveRecord::ConnectionAdapters::Column]
103
- def associated_column
104
- @associated_column ||= if belongs_to_association?
105
- column(association.active_record, associated_key)
106
- else
107
- column(association.klass, associated_key)
108
- end
105
+ # @return [Array<ActiveRecord::ConnectionAdapters::Column>]
106
+ def associated_columns
107
+ @associated_columns ||= associated_keys.map do |associated_key|
108
+ if belongs_to_association?
109
+ column(association.active_record, associated_key)
110
+ else
111
+ column(association.klass, associated_key)
112
+ end
113
+ end
109
114
  end
110
115
 
111
116
  # @return [DatabaseConsistency::Databases::Factory]
@@ -135,6 +140,12 @@ module DatabaseConsistency
135
140
  column.sql_type
136
141
  end
137
142
 
143
+ def covers?(associated_types, primary_types)
144
+ associated_types.zip(primary_types).all? do |associated_type, primary_type|
145
+ associated_type.cover?(primary_type)
146
+ end
147
+ end
148
+
138
149
  # @param [ActiveRecord::ConnectionAdapters::Column]
139
150
  #
140
151
  # @return [DatabaseConsistency::Databases::Types::Base]
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DatabaseConsistency
4
+ module Checkers
5
+ # This class checks for models that need a dependent destroy association
6
+ class MissingDependentDestroyChecker < AssociationChecker
7
+ Report = ReportBuilder.define(
8
+ DatabaseConsistency::Report,
9
+ :model_name,
10
+ :attribute_name
11
+ )
12
+
13
+ DEPENDENT_OPTIONS = %i[destroy delete delete_all nullify restrict_with_error restrict_with_exception].freeze
14
+
15
+ private
16
+
17
+ def preconditions
18
+ association.belongs_to? && !association.polymorphic? && foreign_key
19
+ end
20
+
21
+ def check
22
+ if dependent_destroy_exists? || cascade?
23
+ report_template(:ok)
24
+ else
25
+ report_template(:fail, error_slug: :missing_dependent_destroy)
26
+ end
27
+ end
28
+
29
+ def dependent_destroy_exists?
30
+ association.klass.reflect_on_all_associations.any? do |association|
31
+ %i[has_many has_one].include?(association.macro) &&
32
+ DEPENDENT_OPTIONS.include?(association.options[:dependent]) &&
33
+ association.table_name == model.table_name
34
+ end
35
+ end
36
+
37
+ def cascade?
38
+ %i[cascade nullify].include? foreign_key.options[:on_delete]
39
+ end
40
+
41
+ def report_template(status, error_slug: nil)
42
+ Report.new(
43
+ status: status,
44
+ error_message: nil,
45
+ error_slug: error_slug,
46
+ model_name: association.class_name,
47
+ attribute_name: model.table_name,
48
+ **report_attributes
49
+ )
50
+ end
51
+ end
52
+ end
53
+ end
@@ -16,8 +16,7 @@ module DatabaseConsistency
16
16
 
17
17
  return if subclass.superclass.name == 'DatabaseConsistency::Checkers::BaseChecker'
18
18
 
19
- processor_prefix = subclass.superclass.name.demodulize.delete_suffix('Checker').pluralize
20
- processor = "DatabaseConsistency::Processors::#{processor_prefix}Processor".constantize
19
+ processor = subclass.processor
21
20
  processor.checkers << subclass
22
21
  end
23
22
 
@@ -6,6 +6,10 @@ module DatabaseConsistency
6
6
  class ColumnChecker < BaseChecker
7
7
  attr_reader :model, :column
8
8
 
9
+ def self.processor
10
+ Processors::ColumnsProcessor
11
+ end
12
+
9
13
  def initialize(model, column)
10
14
  super()
11
15
  @model = model
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'prism'
5
+ rescue LoadError
6
+ # Prism is not available; this checker will be disabled on Ruby < 3.3
7
+ end
8
+
9
+ module DatabaseConsistency
10
+ module Checkers
11
+ # This class checks for columns used in find_by queries that are missing a database index.
12
+ # It uses the Prism parser (Ruby stdlib since 3.3) to traverse the AST of all project
13
+ # source files (found by iterating loaded constants and excluding gem paths) and detect
14
+ # calls such as find_by_<column>, find_by(column: ...) and find_by("column" => ...).
15
+ # The checker is automatically skipped on Ruby versions where Prism is not available.
16
+ class MissingIndexFindByChecker < ColumnChecker
17
+ Report = ReportBuilder.define(
18
+ DatabaseConsistency::Report,
19
+ :source_location,
20
+ :total_findings_count
21
+ )
22
+
23
+ private
24
+
25
+ # We skip check when:
26
+ # - Prism is not available (Ruby < 3.3)
27
+ # - column is the primary key (always indexed)
28
+ # - column is a boolean (low-cardinality; single-column indexes are rarely useful)
29
+ # - column name does not appear in any find_by call across project source files
30
+ def preconditions
31
+ defined?(Prism) && !primary_key_column? && !boolean_column? && find_by_used?
32
+ end
33
+
34
+ # Table of possible statuses
35
+ # | index | status |
36
+ # | -------- | ------ |
37
+ # | present | ok |
38
+ # | missing | fail |
39
+ def check
40
+ if indexed?
41
+ report_template(:ok)
42
+ else
43
+ report_template(:fail, error_slug: :missing_index_find_by)
44
+ end
45
+ end
46
+
47
+ def report_template(status, error_slug: nil)
48
+ Report.new(
49
+ status: status,
50
+ error_slug: error_slug,
51
+ error_message: nil,
52
+ source_location: (status == :fail ? @find_by_location : nil),
53
+ total_findings_count: (status == :fail ? @find_by_count : nil),
54
+ **report_attributes
55
+ )
56
+ end
57
+
58
+ def find_by_used?
59
+ entry = PrismHelper.find_by_calls_index.dig(model.name.to_s, column.name.to_s)
60
+ return false unless entry
61
+
62
+ @find_by_location = entry[:first_location]
63
+ @find_by_count = entry[:total_findings_count]
64
+ true
65
+ end
66
+
67
+ def indexed?
68
+ model.connection.indexes(model.table_name).any? do |index|
69
+ Helper.extract_index_columns(index.columns).first == column.name.to_s
70
+ end
71
+ end
72
+
73
+ def primary_key_column?
74
+ column.name.to_s == model.primary_key.to_s
75
+ end
76
+
77
+ def boolean_column?
78
+ column.type == :boolean
79
+ end
80
+
81
+ if defined?(Prism)
82
+ # Prism AST visitor that collects ALL find_by calls from a source file into a results hash.
83
+ # Key: [model_name, column_name] -- model_name is derived from the explicit receiver or the
84
+ # lexical class/module scope for bare calls. Bare calls outside any class are ignored.
85
+ # Value: "file:line" location of the first matching call.
86
+ #
87
+ # Handles:
88
+ # - find_by_<col>(<value>) / Model.find_by_<col>! (dynamic finder)
89
+ # - find_by(col: <value>) / Model.find_by col: (symbol-key hash)
90
+ # - find_by("col" => <value>) (string-key hash)
91
+ #
92
+ # Defined only when Prism is available (Ruby 3.3+).
93
+ class FindByCollector < Prism::Visitor
94
+ # Matches the full column name from a dynamic finder method name.
95
+ # e.g. find_by_email -> "email", find_by_first_name -> "first_name"
96
+ # Multi-column patterns like find_by_name_and_email extract "name_and_email"
97
+ # which won't match any single-column name, so there are no false positives.
98
+ DYNAMIC_FINDER_RE = /\Afind_by_(.+?)!?\z/.freeze
99
+
100
+ attr_reader :results
101
+
102
+ def initialize(file)
103
+ super()
104
+ @file = file
105
+ @results = {}
106
+ @scope_stack = []
107
+ end
108
+
109
+ def visit_class_node(node)
110
+ @scope_stack.push(constant_path_name(node.constant_path))
111
+ super
112
+ ensure
113
+ @scope_stack.pop
114
+ end
115
+
116
+ def visit_module_node(node)
117
+ @scope_stack.push(constant_path_name(node.constant_path))
118
+ super
119
+ ensure
120
+ @scope_stack.pop
121
+ end
122
+
123
+ def visit_call_node(node)
124
+ name = node.name.to_s
125
+ if (match = DYNAMIC_FINDER_RE.match(name))
126
+ model_key = receiver_to_model_key(node.receiver)
127
+ store(model_key, match[1], node) unless model_key == :skip
128
+ elsif name == 'find_by' && node.arguments
129
+ col = single_hash_column(node.arguments)
130
+ model_key = receiver_to_model_key(node.receiver)
131
+ store(model_key, col, node) if col && model_key != :skip
132
+ end
133
+ super
134
+ end
135
+
136
+ private
137
+
138
+ def current_scope
139
+ @scope_stack.empty? ? nil : @scope_stack.join('::')
140
+ end
141
+
142
+ def store(model_key, col, node)
143
+ key = [model_key, col]
144
+ @results[key] ||= []
145
+ @results[key] << "#{@file}:#{node.location.start_line}"
146
+ end
147
+
148
+ def receiver_to_model_key(receiver)
149
+ case receiver
150
+ when nil then current_scope || :skip
151
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
152
+ constant_path_name(receiver)
153
+ when Prism::CallNode
154
+ scoped_receiver_model(receiver)
155
+ else
156
+ :skip
157
+ end
158
+ end
159
+
160
+ def scoped_receiver_model(call_node)
161
+ return :skip unless %w[unscoped includes].include?(call_node.name.to_s)
162
+
163
+ rec = call_node.receiver
164
+ return :skip unless rec.is_a?(Prism::ConstantReadNode) || rec.is_a?(Prism::ConstantPathNode)
165
+
166
+ constant_path_name(rec)
167
+ end
168
+
169
+ def constant_path_name(node)
170
+ case node
171
+ when Prism::ConstantReadNode then node.name.to_s
172
+ when Prism::ConstantPathNode then "#{constant_path_name(node.parent)}::#{node.name}"
173
+ end
174
+ end
175
+
176
+ def single_hash_column(arguments_node)
177
+ arguments_node.arguments.each do |arg|
178
+ next unless arg.is_a?(Prism::KeywordHashNode) && arg.elements.size == 1
179
+
180
+ assoc = arg.elements.first
181
+ next unless assoc.is_a?(Prism::AssocNode)
182
+
183
+ key = assoc.key
184
+ return key.unescaped if key.is_a?(Prism::SymbolNode) || key.is_a?(Prism::StringNode)
185
+ end
186
+ nil
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -69,7 +69,7 @@ module DatabaseConsistency
69
69
  model.validators.any? do |validator|
70
70
  validator.kind == :exclusion &&
71
71
  Helper.check_inclusion?(validator.attributes, column.name) &&
72
- validator.options[:in].include?(nil)
72
+ Helper.inclusion_validator_values(validator).include?(nil)
73
73
  end
74
74
  end
75
75
 
@@ -6,6 +6,10 @@ module DatabaseConsistency
6
6
  class EnumChecker < BaseChecker
7
7
  attr_reader :model, :enum
8
8
 
9
+ def self.processor
10
+ Processors::EnumsProcessor
11
+ end
12
+
9
13
  def initialize(model, enum)
10
14
  super()
11
15
  @model = model
@@ -6,6 +6,10 @@ module DatabaseConsistency
6
6
  class IndexChecker < BaseChecker
7
7
  attr_reader :model, :index
8
8
 
9
+ def self.processor
10
+ Processors::IndexesProcessor
11
+ end
12
+
9
13
  def initialize(model, index)
10
14
  super()
11
15
  @model = model
@@ -16,7 +16,7 @@ module DatabaseConsistency
16
16
  # We skip check when:
17
17
  # - index is unique
18
18
  def preconditions
19
- !index.unique
19
+ !index.unique && Helper.btree_index?(index)
20
20
  end
21
21
 
22
22
  # Table of possible statuses
@@ -48,7 +48,7 @@ module DatabaseConsistency
48
48
  def covered_by_index
49
49
  @covered_by_index ||=
50
50
  model.connection.indexes(model.table_name).find do |another_index|
51
- next if index.name == another_index.name
51
+ next if index.name == another_index.name || !Helper.btree_index?(another_index)
52
52
 
53
53
  clause_equals?(another_index) && include_index_as_prefix?(another_index)
54
54
  end
@@ -29,10 +29,13 @@ module DatabaseConsistency
29
29
  def valid?
30
30
  uniqueness_validators = model.validators.select { |validator| validator.kind == :uniqueness }
31
31
 
32
- uniqueness_validators.any? do |validator|
33
- validator.attributes.any? do |attribute|
34
- sorted_index_columns == Helper.sorted_uniqueness_validator_columns(attribute, validator, model)
35
- end
32
+ uniqueness_validators.any? { |validator| validator_matches?(validator) }
33
+ end
34
+
35
+ def validator_matches?(validator)
36
+ validator.attributes.any? do |attribute|
37
+ sorted_index_columns == Helper.sorted_uniqueness_validator_columns(attribute, validator, model) &&
38
+ Helper.conditions_match_index?(model, attribute, validator, index.where)
36
39
  end
37
40
  end
38
41
 
@@ -6,6 +6,10 @@ module DatabaseConsistency
6
6
  class ModelChecker < BaseChecker
7
7
  attr_reader :model
8
8
 
9
+ def self.processor
10
+ Processors::ModelsProcessor
11
+ end
12
+
9
13
  def initialize(model)
10
14
  super()
11
15
  @model = model
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DatabaseConsistency
4
+ module Checkers
5
+ # This class checks that a model pointing to a view has a primary_key set and that column exists
6
+ class ViewPrimaryKeyChecker < ModelChecker
7
+ private
8
+
9
+ def preconditions
10
+ ActiveRecord::VERSION::MAJOR >= 5 &&
11
+ !model.abstract_class? &&
12
+ model.connection.view_exists?(model.table_name)
13
+ end
14
+
15
+ # Table of possible statuses
16
+ # | primary_key set | column exists | status |
17
+ # | --------------- | ------------- | ------ |
18
+ # | no | - | fail |
19
+ # | yes | no | fail |
20
+ # | yes | yes | ok |
21
+ def check
22
+ if model.primary_key.blank?
23
+ report_template(:fail, error_slug: :view_missing_primary_key)
24
+ elsif !primary_key_column_exists?
25
+ report_template(:fail, error_slug: :view_primary_key_column_missing)
26
+ else
27
+ report_template(:ok)
28
+ end
29
+ end
30
+
31
+ def primary_key_column_exists?
32
+ Array(model.primary_key).all? { |key| model.column_names.include?(key.to_s) }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -28,7 +28,7 @@ module DatabaseConsistency
28
28
  # | persisted | ok |
29
29
  # | missing | fail |
30
30
  def check
31
- if unique_index
31
+ if unique_index || primary_key_covers_validation?
32
32
  report_template(:ok)
33
33
  else
34
34
  report_template(:fail, error_slug: :missing_unique_index)
@@ -47,9 +47,21 @@ module DatabaseConsistency
47
47
  end
48
48
 
49
49
  def unique_index
50
- @unique_index ||= model.connection.indexes(model.table_name).find do |index|
51
- index.unique && Helper.extract_index_columns(index.columns).sort == sorted_uniqueness_validator_columns
52
- end
50
+ @unique_index ||= model.connection.indexes(model.table_name).find { |index| index_matches?(index) }
51
+ end
52
+
53
+ def index_matches?(index)
54
+ index.unique &&
55
+ Helper.extract_index_columns(index.columns).sort == sorted_uniqueness_validator_columns &&
56
+ Helper.conditions_match_index?(model, attribute, validator, index.where)
57
+ end
58
+
59
+ def primary_key_covers_validation?
60
+ primary_key = model.connection.primary_key(model.table_name)
61
+
62
+ return false if primary_key.blank?
63
+
64
+ sorted_uniqueness_validator_columns == Helper.extract_columns(primary_key).sort
53
65
  end
54
66
 
55
67
  def sorted_uniqueness_validator_columns