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
data/lib/database_consistency/checkers/validators_fraction_checkers/validators_fraction_checker.rb
CHANGED
|
@@ -6,6 +6,10 @@ module DatabaseConsistency
|
|
|
6
6
|
class ValidatorsFractionChecker < BaseChecker
|
|
7
7
|
attr_reader :model, :attribute, :validators
|
|
8
8
|
|
|
9
|
+
def self.processor
|
|
10
|
+
Processors::ValidatorsFractionsProcessor
|
|
11
|
+
end
|
|
12
|
+
|
|
9
13
|
def initialize(model, attribute, validators)
|
|
10
14
|
super()
|
|
11
15
|
@model = model
|
|
@@ -8,15 +8,16 @@ module DatabaseConsistency
|
|
|
8
8
|
DEFAULT_PATH = '.database_consistency.yml'
|
|
9
9
|
|
|
10
10
|
def initialize(file_paths = DEFAULT_PATH)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
extract_configurations(existing_paths)
|
|
11
|
+
existing_paths = existing_configurations(file_paths)
|
|
12
|
+
|
|
13
|
+
if existing_paths.any?
|
|
14
|
+
puts "Loaded configurations: #{existing_paths.join(', ')}"
|
|
15
|
+
else
|
|
16
|
+
puts 'No configuration files were provided'
|
|
18
17
|
end
|
|
19
18
|
|
|
19
|
+
@configuration = extract_configurations(existing_paths)
|
|
20
|
+
|
|
20
21
|
load_custom_checkers
|
|
21
22
|
end
|
|
22
23
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DatabaseConsistency
|
|
4
|
+
# The module contains file system helper methods for locating project source files.
|
|
5
|
+
module FilesHelper
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Returns all unique project source file paths (non-gem Ruby files from loaded constants).
|
|
9
|
+
# Memoized so the file system walk happens once per database_consistency run.
|
|
10
|
+
def project_source_files
|
|
11
|
+
@project_source_files ||=
|
|
12
|
+
if Module.respond_to?(:const_source_location)
|
|
13
|
+
collect_source_files
|
|
14
|
+
else
|
|
15
|
+
[]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def collect_source_files
|
|
20
|
+
files = []
|
|
21
|
+
ObjectSpace.each_object(Module) { |mod| files << source_file_path(mod) }
|
|
22
|
+
files.compact.uniq
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def source_file_path(mod)
|
|
26
|
+
name = mod.name
|
|
27
|
+
return unless name
|
|
28
|
+
|
|
29
|
+
file, = Module.const_source_location(name)
|
|
30
|
+
return unless file && File.exist?(file)
|
|
31
|
+
return if excluded_source_file?(file)
|
|
32
|
+
|
|
33
|
+
file
|
|
34
|
+
rescue StandardError, ScriptError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def excluded_source_file?(file)
|
|
39
|
+
return true if defined?(Bundler) && file.include?(Bundler.bundle_path.to_s)
|
|
40
|
+
return true if defined?(Gem) && file.include?(Gem::RUBYGEMS_DIR)
|
|
41
|
+
|
|
42
|
+
excluded_by_ruby_stdlib?(file)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def excluded_by_ruby_stdlib?(file)
|
|
46
|
+
return false unless defined?(RbConfig)
|
|
47
|
+
|
|
48
|
+
file.include?(RbConfig::CONFIG['rubylibdir']) ||
|
|
49
|
+
file.include?(RbConfig::CONFIG['bindir']) ||
|
|
50
|
+
file.include?(RbConfig::CONFIG['sbindir'])
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module DatabaseConsistency
|
|
4
4
|
# The module contains helper methods
|
|
5
|
-
module Helper
|
|
5
|
+
module Helper # rubocop:disable Metrics/ModuleLength
|
|
6
6
|
module_function
|
|
7
7
|
|
|
8
8
|
def adapter
|
|
@@ -49,7 +49,7 @@ module DatabaseConsistency
|
|
|
49
49
|
def connected?(klass)
|
|
50
50
|
klass.connection
|
|
51
51
|
rescue ActiveRecord::ConnectionNotEstablished
|
|
52
|
-
puts "#{klass}
|
|
52
|
+
puts "#{klass} does not have an active connection, skipping"
|
|
53
53
|
false
|
|
54
54
|
end
|
|
55
55
|
|
|
@@ -112,10 +112,223 @@ module DatabaseConsistency
|
|
|
112
112
|
end
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
+
def inclusion_validator_values(validator)
|
|
116
|
+
value = validator.options[:in]
|
|
117
|
+
|
|
118
|
+
if value.is_a?(Proc) && value.arity.zero?
|
|
119
|
+
value.call
|
|
120
|
+
else
|
|
121
|
+
Array.wrap(value)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def btree_index?(index)
|
|
126
|
+
(index.type.nil? || index.type.to_s == 'btree') &&
|
|
127
|
+
(index.using.nil? || index.using.to_s == 'btree')
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def extract_columns(str)
|
|
131
|
+
case str
|
|
132
|
+
when Array
|
|
133
|
+
str.map(&:to_s)
|
|
134
|
+
when String
|
|
135
|
+
str.scan(/(\w+)/).flatten
|
|
136
|
+
when Symbol
|
|
137
|
+
[str.to_s]
|
|
138
|
+
else
|
|
139
|
+
raise "Unexpected type for columns: #{str.class} with value: #{str}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
115
143
|
def foreign_key_or_attribute(model, attribute)
|
|
116
144
|
model._reflect_on_association(attribute)&.foreign_key || attribute
|
|
117
145
|
end
|
|
118
146
|
|
|
147
|
+
# Returns the normalized WHERE SQL produced by a conditions proc, or nil if
|
|
148
|
+
# it cannot be determined (complex proc, unsupported AR version, etc.).
|
|
149
|
+
def conditions_where_sql(model, conditions)
|
|
150
|
+
sql = model.unscoped.instance_exec(&conditions).to_sql
|
|
151
|
+
where_part = sql.split(/\bWHERE\b/i, 2).last
|
|
152
|
+
return nil unless where_part
|
|
153
|
+
|
|
154
|
+
normalize_condition_sql(where_part.gsub("#{model.quoted_table_name}.", '').gsub('"', ''))
|
|
155
|
+
rescue StandardError
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Builds the effective uniqueness constraint enforced by a validator by
|
|
160
|
+
# combining its explicit `conditions` proc with implicit guards such as
|
|
161
|
+
# `allow_nil` / `allow_blank`.
|
|
162
|
+
def uniqueness_validator_where_sql(model, attribute, validator)
|
|
163
|
+
conditions_sql = conditions_where_sql(model, validator.options[:conditions])
|
|
164
|
+
guard_sql = uniqueness_validator_guard_sql(model, attribute, validator)
|
|
165
|
+
|
|
166
|
+
sql_parts = [conditions_sql, guard_sql].reject { |part| part.nil? || part == '' }
|
|
167
|
+
return nil if sql_parts.empty?
|
|
168
|
+
|
|
169
|
+
normalize_condition_sql(sql_parts.join(' AND '))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Returns true when validator conditions and index WHERE clause are a valid
|
|
173
|
+
# pairing: both absent means a match; exactly one present means no match;
|
|
174
|
+
# when both present the normalized SQL is compared.
|
|
175
|
+
def conditions_match_index?(model, attribute, validator, index_where)
|
|
176
|
+
validator_where = uniqueness_validator_where_sql(model, attribute, validator)
|
|
177
|
+
return true if validator_where.nil? && index_where.blank?
|
|
178
|
+
return true if index_where.blank? && validator_guard_only?(model, attribute, validator)
|
|
179
|
+
return false if validator_where.nil? || index_where.blank?
|
|
180
|
+
|
|
181
|
+
normalized_where = normalize_condition_sql(index_where)
|
|
182
|
+
validator_where.casecmp?(normalized_where)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Normalizes SQL predicates into a canonical form so semantically equivalent
|
|
186
|
+
# Rails validators and database partial indexes can be compared safely.
|
|
187
|
+
def normalize_condition_sql(sql)
|
|
188
|
+
sql
|
|
189
|
+
.to_s
|
|
190
|
+
.then { |value| strip_outer_parentheses(value) }
|
|
191
|
+
.then { |value| normalize_sql(value) }
|
|
192
|
+
.then { |value| normalize_boolean_predicates(value) }
|
|
193
|
+
.then { |value| normalize_array_any_predicates(value) }
|
|
194
|
+
.then { |value| normalize_negated_blank_or_nil_predicates(value) }
|
|
195
|
+
.then { |value| sort_and_clauses(value) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Applies lightweight SQL normalization without changing the logical meaning.
|
|
199
|
+
def normalize_sql(sql)
|
|
200
|
+
# `/::\w+/` removes PostgreSQL casts like `column::text`.
|
|
201
|
+
normalized_sql = sql.gsub(/::\w+/, '')
|
|
202
|
+
# `/\(([a-z_][\w.]*)\)/i` unwraps a bare identifier surrounded by
|
|
203
|
+
# parentheses, e.g. `(internal_name)` -> `internal_name`.
|
|
204
|
+
normalized_sql = normalized_sql.gsub(/\(([a-z_][\w.]*)\)/i, '\1')
|
|
205
|
+
# `/\bTRUE\b/i` and `/\bFALSE\b/i` normalize boolean literals to `1` / `0`
|
|
206
|
+
# so they match SQL generated by Active Record on some adapters.
|
|
207
|
+
normalized_sql = normalized_sql.gsub(/\bTRUE\b/i, '1').gsub(/\bFALSE\b/i, '0')
|
|
208
|
+
# `/\s*<>\s*/` rewrites the SQL inequality operator `<>` to `!=`.
|
|
209
|
+
normalized_sql = normalized_sql.gsub(/\s*<>\s*/, ' != ')
|
|
210
|
+
# `/\bIS\s+NOT\s+NULL\b/i` normalizes `IS NOT NULL` spacing and casing.
|
|
211
|
+
normalized_sql = normalized_sql.gsub(/\bIS\s+NOT\s+NULL\b/i, ' IS NOT NULL')
|
|
212
|
+
# `/\bIS\s+NULL\b/i` normalizes `IS NULL` spacing and casing.
|
|
213
|
+
normalized_sql = normalized_sql.gsub(/\bIS\s+NULL\b/i, ' IS NULL')
|
|
214
|
+
# `/ = 't'/` and `/ = 'f'/` normalize PostgreSQL boolean literals stored
|
|
215
|
+
# as `'t'` / `'f'` inside comparisons.
|
|
216
|
+
normalized_sql = normalized_sql.gsub(/ = 't'/, ' = 1').gsub(/ = 'f'/, ' = 0')
|
|
217
|
+
# `/\s+/` collapses any run of whitespace to a single space.
|
|
218
|
+
normalized_sql = normalized_sql.gsub(/\s+/, ' ')
|
|
219
|
+
normalized_sql.strip
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Repeatedly removes one wrapping layer of parentheses when the whole SQL
|
|
223
|
+
# fragment is enclosed, e.g. `((foo))` -> `foo`.
|
|
224
|
+
def strip_outer_parentheses(sql)
|
|
225
|
+
stripped_sql = sql.strip
|
|
226
|
+
|
|
227
|
+
stripped_sql = stripped_sql[1..-2].strip while wrapped_with_parentheses?(stripped_sql)
|
|
228
|
+
|
|
229
|
+
stripped_sql
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Returns true only when the string is entirely wrapped by one outer pair of
|
|
233
|
+
# parentheses, not when parentheses close earlier inside the expression.
|
|
234
|
+
def wrapped_with_parentheses?(sql)
|
|
235
|
+
return false unless sql.start_with?('(') && sql.end_with?(')')
|
|
236
|
+
|
|
237
|
+
depth = 0
|
|
238
|
+
|
|
239
|
+
sql[1..-2].each_char do |char|
|
|
240
|
+
depth = parenthesis_depth(depth, char)
|
|
241
|
+
return false if depth.negative?
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
depth.zero?
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Tracks parenthesis nesting depth character by character.
|
|
248
|
+
def parenthesis_depth(depth, char)
|
|
249
|
+
case char
|
|
250
|
+
when '('
|
|
251
|
+
depth + 1
|
|
252
|
+
when ')'
|
|
253
|
+
depth - 1
|
|
254
|
+
else
|
|
255
|
+
depth
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Rewrites shorthand boolean predicates into explicit comparisons so
|
|
260
|
+
# `flag` and `NOT flag` line up with `flag = true/false`.
|
|
261
|
+
def normalize_boolean_predicates(sql)
|
|
262
|
+
normalized_sql = sql.dup
|
|
263
|
+
|
|
264
|
+
# Matches a bare negated boolean predicate such as `NOT archived`
|
|
265
|
+
# appearing at the start of an expression, after `AND` / `OR`, or after
|
|
266
|
+
# an opening parenthesis, and rewrites it to `archived = 0`.
|
|
267
|
+
normalized_sql.gsub!(
|
|
268
|
+
/(^|(?:\bAND\b|\bOR\b|\())\s*NOT\s+([a-z_][\w.]*)\s*(?=$|(?:\bAND\b|\bOR\b|\)))/i
|
|
269
|
+
) { "#{Regexp.last_match(1)} #{Regexp.last_match(2)} = 0" }
|
|
270
|
+
|
|
271
|
+
# Matches a bare boolean predicate such as `most_recent` appearing in the
|
|
272
|
+
# same structural positions, and rewrites it to `most_recent = 1`.
|
|
273
|
+
normalized_sql.gsub!(
|
|
274
|
+
/(^|(?:\bAND\b|\bOR\b|\())\s*([a-z_][\w.]*)\s*(?=$|(?:\bAND\b|\bOR\b|\)))/i
|
|
275
|
+
) { "#{Regexp.last_match(1)} #{Regexp.last_match(2)} = 1" }
|
|
276
|
+
|
|
277
|
+
normalized_sql.gsub(/\s+/, ' ').strip
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Rewrites PostgreSQL's `= ANY (ARRAY[...])` form into an `IN (...)` form
|
|
281
|
+
# so it matches the SQL Active Record typically generates for arrays.
|
|
282
|
+
def normalize_array_any_predicates(sql)
|
|
283
|
+
sql.gsub(
|
|
284
|
+
# Matches `column = ANY (ARRAY[...])`, capturing the column name and the
|
|
285
|
+
# full array payload so it can be converted to `column IN (...)`.
|
|
286
|
+
/([a-z_][\w.]*)\s*=\s*ANY\s*\(ARRAY\[(.*?)\]\)/i
|
|
287
|
+
) { "#{Regexp.last_match(1)} IN (#{Regexp.last_match(2).gsub(/\s+/, ' ').strip})" }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Rewrites negated "blank or nil" predicates into the same shape used by
|
|
291
|
+
# `allow_blank`-derived guards: `IS NOT NULL AND != ''`.
|
|
292
|
+
def normalize_negated_blank_or_nil_predicates(sql)
|
|
293
|
+
sql.gsub(
|
|
294
|
+
# Matches SQL like `NOT (column = '' OR column IS NULL)` while enforcing
|
|
295
|
+
# the same column name on both sides via backreference `\1`.
|
|
296
|
+
/NOT\s+\(\s*\(?([a-z_][\w.]*)\s*=\s*''\s+OR\s+\1\s+IS\s+NULL\)?\s*\)/i
|
|
297
|
+
) { "#{Regexp.last_match(1)} IS NOT NULL AND #{Regexp.last_match(1)} != ''" }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Sorts simple `AND` clauses so `a AND b` and `b AND a` normalize to the
|
|
301
|
+
# same string before comparison.
|
|
302
|
+
def sort_and_clauses(sql)
|
|
303
|
+
# Matches `AND` with surrounding whitespace and splits the expression into
|
|
304
|
+
# comparable clause fragments.
|
|
305
|
+
clauses = sql.split(/\s+AND\s+/i)
|
|
306
|
+
return sql if clauses.length == 1
|
|
307
|
+
|
|
308
|
+
clauses.map! { |clause| strip_outer_parentheses(clause) }
|
|
309
|
+
clauses.sort.join(' AND ')
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Builds the implicit SQL guard introduced by validator options that skip
|
|
313
|
+
# nil or blank values instead of validating them.
|
|
314
|
+
def uniqueness_validator_guard_sql(model, attribute, validator)
|
|
315
|
+
attribute_name = foreign_key_or_attribute(model, attribute).to_s
|
|
316
|
+
|
|
317
|
+
if validator.options[:allow_blank]
|
|
318
|
+
"#{attribute_name} IS NOT NULL AND #{attribute_name} != ''"
|
|
319
|
+
elsif validator.options[:allow_nil]
|
|
320
|
+
"#{attribute_name} IS NOT NULL"
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# A validator with only `allow_nil` / `allow_blank` and no explicit
|
|
325
|
+
# conditions is still satisfied by a full unique index, because the database
|
|
326
|
+
# constraint is stricter than the validator.
|
|
327
|
+
def validator_guard_only?(model, attribute, validator)
|
|
328
|
+
uniqueness_validator_guard_sql(model, attribute, validator).present? &&
|
|
329
|
+
validator.options[:conditions].nil?
|
|
330
|
+
end
|
|
331
|
+
|
|
119
332
|
# @return [String]
|
|
120
333
|
def wrapped_attribute_name(attribute, validator, model)
|
|
121
334
|
attribute = foreign_key_or_attribute(model, attribute)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DatabaseConsistency
|
|
4
|
+
# The module contains Prism AST helper methods for scanning project source files.
|
|
5
|
+
module PrismHelper
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Returns a memoized index: {model_name => {column_name => "file:line"}}.
|
|
9
|
+
# Built once per run by scanning all project source files with Prism (Ruby 3.3+).
|
|
10
|
+
# Bare find_by calls are resolved to their lexical class/module scope.
|
|
11
|
+
def find_by_calls_index
|
|
12
|
+
return {} unless defined?(Prism)
|
|
13
|
+
|
|
14
|
+
@find_by_calls_index ||= build_find_by_calls_index
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def build_find_by_calls_index
|
|
18
|
+
FilesHelper.project_source_files.each_with_object({}) do |file, index|
|
|
19
|
+
collector = Checkers::MissingIndexFindByChecker::FindByCollector.new(file)
|
|
20
|
+
collector.visit(Prism.parse_file(file).value)
|
|
21
|
+
merge_collector_results(collector.results, index)
|
|
22
|
+
rescue StandardError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def merge_collector_results(results, index)
|
|
28
|
+
results.each do |(model_key, col), locations|
|
|
29
|
+
index[model_key] ||= {}
|
|
30
|
+
if (entry = index[model_key][col])
|
|
31
|
+
entry[:total_findings_count] += locations.size
|
|
32
|
+
else
|
|
33
|
+
index[model_key][col] = { first_location: locations.first, total_findings_count: locations.size }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -16,9 +16,9 @@ module DatabaseConsistency
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def initialize
|
|
19
|
-
puts '
|
|
20
|
-
puts "Attach the
|
|
21
|
-
puts 'Thank you
|
|
19
|
+
puts 'Some checks failed with an error. Please open an issue on GitHub at https://github.com/djezzzl/database_consistency.'
|
|
20
|
+
puts "Attach the generated file: #{filename}"
|
|
21
|
+
puts 'Thank you for your contribution!'
|
|
22
22
|
puts '(c) Evgeniy Demin <lawliet.djez@gmail.com>'
|
|
23
23
|
end
|
|
24
24
|
|
|
@@ -10,7 +10,7 @@ module DatabaseConsistency
|
|
|
10
10
|
file_path = migration_path(migration_name)
|
|
11
11
|
|
|
12
12
|
if Dir[migration_path_pattern(migration_name)].any?
|
|
13
|
-
|
|
13
|
+
puts "Skipping migration #{migration_name} because it already exists"
|
|
14
14
|
else
|
|
15
15
|
File.write(file_path, migration)
|
|
16
16
|
end
|
|
@@ -5,6 +5,8 @@ module DatabaseConsistency
|
|
|
5
5
|
module Writers
|
|
6
6
|
# The simplest formatter
|
|
7
7
|
class AutofixWriter < BaseWriter
|
|
8
|
+
UnknownCheckerError = Class.new(StandardError)
|
|
9
|
+
|
|
8
10
|
SLUG_TO_GENERATOR = {
|
|
9
11
|
association_missing_index: Autofix::AssociationMissingIndex,
|
|
10
12
|
association_missing_null_constraint: Autofix::NullConstraintMissing,
|
|
@@ -20,6 +22,29 @@ module DatabaseConsistency
|
|
|
20
22
|
three_state_boolean: Autofix::NullConstraintMissing
|
|
21
23
|
}.freeze
|
|
22
24
|
|
|
25
|
+
class << self
|
|
26
|
+
def validate_scope!(scope)
|
|
27
|
+
return if scope.nil?
|
|
28
|
+
|
|
29
|
+
known = concrete_checker_names
|
|
30
|
+
unknown = scope - known
|
|
31
|
+
return if unknown.empty?
|
|
32
|
+
|
|
33
|
+
raise UnknownCheckerError,
|
|
34
|
+
"unknown checker(s): #{unknown.join(', ')}. Known: #{known.join(', ')}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def concrete_checker_names
|
|
40
|
+
Checkers::BaseChecker.descendants
|
|
41
|
+
.reject { |checker| checker.superclass == Checkers::BaseChecker }
|
|
42
|
+
.map(&:checker_name)
|
|
43
|
+
.uniq
|
|
44
|
+
.sort
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
23
48
|
def write
|
|
24
49
|
unique_generators.each(&:fix!)
|
|
25
50
|
end
|
|
@@ -35,7 +60,13 @@ module DatabaseConsistency
|
|
|
35
60
|
end
|
|
36
61
|
|
|
37
62
|
def fix?(report)
|
|
38
|
-
report.status == :fail
|
|
63
|
+
report.status == :fail && scoped?(report)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def scoped?(report)
|
|
67
|
+
return true if opts.nil?
|
|
68
|
+
|
|
69
|
+
opts.include?(report.checker_name)
|
|
39
70
|
end
|
|
40
71
|
|
|
41
72
|
def generator(report)
|
|
@@ -4,15 +4,16 @@ module DatabaseConsistency
|
|
|
4
4
|
module Writers
|
|
5
5
|
# The base class for writers
|
|
6
6
|
class BaseWriter
|
|
7
|
-
attr_reader :results, :config
|
|
7
|
+
attr_reader :results, :config, :opts
|
|
8
8
|
|
|
9
|
-
def initialize(results, config: Configuration.new)
|
|
9
|
+
def initialize(results, config: Configuration.new, opts: nil)
|
|
10
10
|
@results = results
|
|
11
11
|
@config = config
|
|
12
|
+
@opts = opts
|
|
12
13
|
end
|
|
13
14
|
|
|
14
|
-
def self.write(results, config: Configuration.new)
|
|
15
|
-
new(results, config: config).write
|
|
15
|
+
def self.write(results, config: Configuration.new, opts: nil)
|
|
16
|
+
new(results, config: config, opts: opts).write
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
end
|
|
@@ -7,7 +7,7 @@ module DatabaseConsistency
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
9
|
def template
|
|
10
|
-
'enum has [%<enum_values>s]
|
|
10
|
+
'database enum has values [%<enum_values>s] but ActiveRecord enum has values [%<declared_values>s]'
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def attributes
|
|
@@ -7,7 +7,7 @@ module DatabaseConsistency
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
9
|
def template
|
|
10
|
-
'enum has [%<enum_values>s]
|
|
10
|
+
'database enum has values [%<enum_values>s] but inclusion validation has values [%<declared_values>s]'
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def attributes
|
|
@@ -7,8 +7,8 @@ module DatabaseConsistency
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
9
|
def template
|
|
10
|
-
|
|
11
|
-
'
|
|
10
|
+
'foreign key (%<fk_name>s) with type (%<fk_type>s) does not match '\
|
|
11
|
+
'primary key (%<pk_name>s) with type (%<pk_type>s)'
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def attributes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DatabaseConsistency
|
|
4
|
+
module Writers
|
|
5
|
+
module Simple
|
|
6
|
+
class MissingDependentDestroy < Base # :nodoc:
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def template
|
|
10
|
+
'should have a corresponding has_one/has_many association with dependent option (destroy, delete, delete_all, nullify) or a foreign key with on_delete (cascade, nullify)' # rubocop:disable Layout/LineLength
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def unique_attributes
|
|
14
|
+
{
|
|
15
|
+
model_name: report.model_name,
|
|
16
|
+
attribute_name: report.attribute_name
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|