database_consistency 3.0.2 → 3.0.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f837e1b5dec95bfd802c388c77ad279277c50862d9b8d5ea230c655f2ddad0a
4
- data.tar.gz: 86524123737c51e76311d330149f7a4985cb1fbf177e528c289177f749dbf334
3
+ metadata.gz: 4830aa2e788dbbee5e54ce2748509ef5b8bfab2c76e4355edcefff9ce04775de
4
+ data.tar.gz: 26e284719ae0b7289164b34a6b0e9ccfaca84bbbd473451ec9114cd0fe57457b
5
5
  SHA512:
6
- metadata.gz: ef3ee3c55c9fbd3ae445b0b5aef247f1ca25ebc683d1e8d2bd8ffcdb2fb09d1cf9ccb89d5611bd414f53d1f7f3102f92c9732559381a0d8a0166661cad4b7349
7
- data.tar.gz: 0155cdcc840cacd1292656249c1ea4942a01aee93b5490dad2e092d4d76edae37abd09ae704350ade681264d6b9a3e6d8cebf0db333c9de8651f5d11600c499f
6
+ metadata.gz: 8a6668676b8aca9c6e4dd55768c5c1c94b37c9de320ae38a535d8a69b49b164940a3ed7f5c2e12de24ccb208d7100863fa54cf715b879c99ca07850bb4565a46
7
+ data.tar.gz: be80eee01a075040572372ddcba789ac392003f0dc269a491a810b7acbe05bfafd05c60702f0d2f7aed7785547c97fe1f27dbff25d4679aaeaa0af0cc1a44141
@@ -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
@@ -35,7 +35,7 @@ module DatabaseConsistency
35
35
  def validator_matches?(validator)
36
36
  validator.attributes.any? do |attribute|
37
37
  sorted_index_columns == Helper.sorted_uniqueness_validator_columns(attribute, validator, model) &&
38
- Helper.conditions_match_index?(model, validator.options[:conditions], index.where)
38
+ Helper.conditions_match_index?(model, attribute, validator, index.where)
39
39
  end
40
40
  end
41
41
 
@@ -53,7 +53,7 @@ module DatabaseConsistency
53
53
  def index_matches?(index)
54
54
  index.unique &&
55
55
  Helper.extract_index_columns(index.columns).sort == sorted_uniqueness_validator_columns &&
56
- Helper.conditions_match_index?(model, validator.options[:conditions], index.where)
56
+ Helper.conditions_match_index?(model, attribute, validator, index.where)
57
57
  end
58
58
 
59
59
  def primary_key_covers_validation?
@@ -151,29 +151,182 @@ module DatabaseConsistency
151
151
  where_part = sql.split(/\bWHERE\b/i, 2).last
152
152
  return nil unless where_part
153
153
 
154
- normalize_sql(where_part.gsub("#{model.quoted_table_name}.", '').gsub('"', ''))
154
+ normalize_condition_sql(where_part.gsub("#{model.quoted_table_name}.", '').gsub('"', ''))
155
155
  rescue StandardError
156
156
  nil
157
157
  end
158
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
+
159
172
  # Returns true when validator conditions and index WHERE clause are a valid
160
173
  # pairing: both absent means a match; exactly one present means no match;
161
174
  # when both present the normalized SQL is compared.
162
- def conditions_match_index?(model, conditions, index_where)
163
- return true if conditions.nil? && index_where.blank?
164
- return false if conditions.nil? || index_where.blank?
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
165
184
 
166
- conditions_sql = conditions_where_sql(model, conditions)
167
- # Strip one level of outer parentheses that some databases (e.g. PostgreSQL)
168
- # add when storing/returning the index WHERE clause.
169
- normalized_where = normalize_sql(index_where.sub(/\A\s*\((.+)\)\s*\z/m, '\1'))
170
- conditions_sql&.casecmp?(normalized_where)
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) }
171
196
  end
172
197
 
198
+ # Applies lightweight SQL normalization without changing the logical meaning.
173
199
  def normalize_sql(sql)
174
- sql.gsub(/\bTRUE\b/i, '1').gsub(/\bFALSE\b/i, '0')
175
- .gsub(/ = 't'/, ' = 1').gsub(/ = 'f'/, ' = 0')
176
- .strip
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?
177
330
  end
178
331
 
179
332
  # @return [String]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DatabaseConsistency
4
- VERSION = '3.0.2'
4
+ VERSION = '3.0.4'
5
5
  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
@@ -116,12 +116,15 @@ require 'database_consistency/checkers/index_checkers/redundant_unique_index_che
116
116
  # The root module
117
117
  module DatabaseConsistency
118
118
  class << self
119
- def run(*args, **opts) # rubocop:disable Metrics/MethodLength
119
+ def run(*args, **opts) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
120
120
  configuration = Configuration.new(*args)
121
+
122
+ Writers::AutofixWriter.validate_scope!(opts[:only_checkers]) if opts[:autofix]
123
+
121
124
  reports = Processors.reports(configuration)
122
125
 
123
126
  if opts[:autofix]
124
- Writers::AutofixWriter.write(reports, config: configuration)
127
+ Writers::AutofixWriter.write(reports, config: configuration, opts: opts[:only_checkers])
125
128
 
126
129
  0
127
130
  elsif opts[:todo]
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: database_consistency
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.2
4
+ version: 3.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Evgeniy Demin
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-21 00:00:00.000000000 Z
11
+ date: 2026-04-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -275,7 +275,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
275
275
  requirements:
276
276
  - - ">="
277
277
  - !ruby/object:Gem::Version
278
- version: 2.4.0
278
+ version: 2.6.0
279
279
  required_rubygems_version: !ruby/object:Gem::Requirement
280
280
  requirements:
281
281
  - - ">="