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.
- checksums.yaml +4 -4
- data/bin/database_consistency +19 -2
- data/lib/database_consistency/checkers/association_checkers/association_checker.rb +11 -0
- data/lib/database_consistency/checkers/association_checkers/foreign_key_cascade_checker.rb +5 -4
- data/lib/database_consistency/checkers/association_checkers/foreign_key_checker.rb +1 -1
- data/lib/database_consistency/checkers/association_checkers/foreign_key_type_checker.rb +46 -35
- data/lib/database_consistency/checkers/association_checkers/missing_dependent_destroy_checker.rb +53 -0
- data/lib/database_consistency/checkers/base_checker.rb +1 -2
- data/lib/database_consistency/checkers/column_checkers/column_checker.rb +4 -0
- data/lib/database_consistency/checkers/column_checkers/missing_index_find_by_checker.rb +192 -0
- data/lib/database_consistency/checkers/column_checkers/null_constraint_checker.rb +1 -1
- data/lib/database_consistency/checkers/enum_checkers/enum_checker.rb +4 -0
- data/lib/database_consistency/checkers/index_checkers/index_checker.rb +4 -0
- data/lib/database_consistency/checkers/index_checkers/redundant_index_checker.rb +2 -2
- data/lib/database_consistency/checkers/index_checkers/unique_index_checker.rb +7 -4
- data/lib/database_consistency/checkers/model_checkers/model_checker.rb +4 -0
- data/lib/database_consistency/checkers/model_checkers/view_primary_key_checker.rb +36 -0
- data/lib/database_consistency/checkers/validator_checkers/missing_unique_index_checker.rb +16 -4
- data/lib/database_consistency/checkers/validator_checkers/validator_checker.rb +4 -0
- data/lib/database_consistency/checkers/validators_fraction_checkers/validators_fraction_checker.rb +4 -0
- data/lib/database_consistency/configuration.rb +8 -7
- data/lib/database_consistency/debug_context.rb +0 -1
- data/lib/database_consistency/files_helper.rb +53 -0
- data/lib/database_consistency/helper.rb +215 -2
- data/lib/database_consistency/prism_helper.rb +38 -0
- data/lib/database_consistency/rescue_error.rb +3 -3
- data/lib/database_consistency/version.rb +1 -1
- data/lib/database_consistency/writers/autofix/migration_base.rb +1 -1
- data/lib/database_consistency/writers/autofix_writer.rb +32 -1
- data/lib/database_consistency/writers/base_writer.rb +5 -4
- data/lib/database_consistency/writers/simple/association_foreign_type_missing_null_constraint.rb +1 -1
- data/lib/database_consistency/writers/simple/association_missing_index.rb +1 -1
- data/lib/database_consistency/writers/simple/association_missing_null_constraint.rb +1 -1
- data/lib/database_consistency/writers/simple/enum_values_inconsistent_with_ar_enum.rb +1 -1
- data/lib/database_consistency/writers/simple/enum_values_inconsistent_with_inclusion.rb +1 -1
- data/lib/database_consistency/writers/simple/has_one_missing_unique_index.rb +1 -1
- data/lib/database_consistency/writers/simple/implicit_order_column_missing.rb +1 -1
- data/lib/database_consistency/writers/simple/inconsistent_enum_type.rb +1 -1
- data/lib/database_consistency/writers/simple/inconsistent_types.rb +2 -2
- data/lib/database_consistency/writers/simple/length_validator_greater_limit.rb +1 -1
- data/lib/database_consistency/writers/simple/length_validator_lower_limit.rb +1 -1
- data/lib/database_consistency/writers/simple/length_validator_missing.rb +1 -1
- data/lib/database_consistency/writers/simple/missing_association_class.rb +1 -1
- data/lib/database_consistency/writers/simple/missing_dependent_destroy.rb +22 -0
- data/lib/database_consistency/writers/simple/missing_foreign_key.rb +1 -1
- data/lib/database_consistency/writers/simple/missing_foreign_key_cascade.rb +1 -1
- data/lib/database_consistency/writers/simple/missing_index_find_by.rb +32 -0
- data/lib/database_consistency/writers/simple/missing_table.rb +1 -1
- data/lib/database_consistency/writers/simple/missing_unique_index.rb +1 -1
- data/lib/database_consistency/writers/simple/missing_uniqueness_validation.rb +1 -1
- data/lib/database_consistency/writers/simple/null_constraint_association_misses_validator.rb +1 -1
- data/lib/database_consistency/writers/simple/null_constraint_misses_validator.rb +1 -1
- data/lib/database_consistency/writers/simple/null_constraint_missing.rb +1 -1
- data/lib/database_consistency/writers/simple/possible_null.rb +1 -1
- data/lib/database_consistency/writers/simple/redundant_case_insensitive_option.rb +1 -1
- data/lib/database_consistency/writers/simple/redundant_index.rb +1 -1
- data/lib/database_consistency/writers/simple/redundant_unique_index.rb +1 -1
- data/lib/database_consistency/writers/simple/small_primary_key.rb +1 -1
- data/lib/database_consistency/writers/simple/three_state_boolean.rb +1 -1
- data/lib/database_consistency/writers/simple/view_missing_primary_key.rb +21 -0
- data/lib/database_consistency/writers/simple/view_primary_key_column_missing.rb +21 -0
- data/lib/database_consistency.rb +14 -7
- metadata +13 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f88fd56f01f639d5614fa82f5e76f6d4a4dfb05c04c246f5baeedf93fd01fe24
|
|
4
|
+
data.tar.gz: 0bb41479e542c995569b54a6cfc188ea39103c30a074560f0bae15f8223c1128
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 54baea922639aec00a1c1edb9aaef5e0d70ec91a2ce63552fcb2ab502b93c1a2779c3227fdb55c7cb03afd17a346624cfc192d84c07480583b44bba00554eea5
|
|
7
|
+
data.tar.gz: 69a60f5b359e2bfc89765756115e510646d6167fd2819da7f25451302c52f1f54c2c590e1a487eb2291c9e747a54e80bf4e193b9db506bc13f8cc7a580b0be8c
|
data/bin/database_consistency
CHANGED
|
@@ -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',
|
|
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
|
-
|
|
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|
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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(
|
|
64
|
-
fk_name:
|
|
65
|
-
pk_type: converted_type(primary_column).type,
|
|
66
|
-
pk_name:
|
|
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
|
|
81
|
-
@
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
90
|
-
@
|
|
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
|
|
95
|
-
@
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
104
|
-
@
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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]
|
data/lib/database_consistency/checkers/association_checkers/missing_dependent_destroy_checker.rb
ADDED
|
@@ -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
|
-
|
|
20
|
-
processor = "DatabaseConsistency::Processors::#{processor_prefix}Processor".constantize
|
|
19
|
+
processor = subclass.processor
|
|
21
20
|
processor.checkers << subclass
|
|
22
21
|
end
|
|
23
22
|
|
|
@@ -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.
|
|
72
|
+
Helper.inclusion_validator_values(validator).include?(nil)
|
|
73
73
|
end
|
|
74
74
|
end
|
|
75
75
|
|
|
@@ -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?
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
|
@@ -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
|
|
51
|
-
|
|
52
|
-
|
|
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
|