database_consistency 3.0.14 → 3.0.16

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: c0599ad1c39057b6a4ec4bb2c60938454a92296906258cda66b5b9f47081447e
4
- data.tar.gz: 77d57bb76d65535e3dea3dfa88d8b5efcce14a230682ee472fd26a1f605c619a
3
+ metadata.gz: 1cb0dbacf9e21959370981f0eaa7bee89164144821af616d59f7b5e90f9a0b31
4
+ data.tar.gz: 7f7e86c94cce08b9b1d858d8099006ac19b58aae8b5e91c4e898ed3289928afd
5
5
  SHA512:
6
- metadata.gz: 9cffcfa84c2731c584788352910c712f9947cc4fcdc73f24bb68128889f6ec0674accdef8d2390d113fb4f16c7e37e5c859dd09fe649d75b2be04b4964fc10c3
7
- data.tar.gz: 3fc92238031f7e9e3611179a5fcad66b8105374eaa799416ae426248a9f88b7baafd61642e98a2eb03d3db0d591cf241d7515388f309b058dca2c20fd04d783d
6
+ metadata.gz: 4caf4a0b2bfc7d5887dfb5052b2ec79a979e31589fa357a709f77819ee837a2592f858d8332c1543ce783992e4279aa16f1f53079abaf93a619cd63419d19209
7
+ data.tar.gz: a3adc09c9b41d1acc40dbbd393430f4db3e07f3aa0d78a5c97854971cd0ad33beaa7b73db012c6360b36adfaf11794df617db89885a2e7981ed547d3a9837563
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DatabaseConsistency
4
+ module Helper
5
+ # The `AND` clauses of a SQL predicate: which `AND`s actually join two
6
+ # clauses, and what the clause list looks like once nested conjunctions have
7
+ # been flattened into it.
8
+ module AndClauses
9
+ module_function
10
+
11
+ # Sorts top-level clauses so `a AND b` and `b AND a` reach the same string
12
+ # before comparison. Clauses are kept verbatim so parentheses binding an
13
+ # `OR` survive. The block, when given, supplies the value each clause is
14
+ # ordered by, which lets a caller order by a clause's real text while the
15
+ # clauses themselves still carry masked literals.
16
+ def sort(sql, &sort_key)
17
+ return sql if top_level_or?(sql)
18
+
19
+ clauses = split(sql)
20
+ return sql if clauses.length == 1
21
+
22
+ clauses.sort_by { |clause| sort_key ? sort_key.call(clause) : clause }.join(' AND ')
23
+ end
24
+
25
+ # `a AND b OR c` means `(a AND b) OR c`, so ordering the clauses of such a
26
+ # predicate would move one across the `OR` and change what it means.
27
+ # Active Record's `or` generates exactly this shape. A predicate carrying
28
+ # one is left alone; `split` assumes a conjunction and is not asked.
29
+ def top_level_or?(sql)
30
+ depth = 0
31
+
32
+ sql.scan(/[()]|\bOR\b/i) do |token|
33
+ case token
34
+ when '(' then depth += 1
35
+ when ')' then depth -= 1
36
+ else return true if depth.zero?
37
+ end
38
+ end
39
+
40
+ false
41
+ end
42
+
43
+ # Splits on the `AND`s that actually join two clauses: one inside a
44
+ # parenthesized group, or the one completing a `BETWEEN` range, belongs to
45
+ # its clause instead. A group whose own top level is a conjunction is
46
+ # flattened, so `a AND (b AND c)` and `a AND b AND c` split the same way.
47
+ def split(sql)
48
+ sql.split(/\s+AND\s+/i)
49
+ .each_with_object([]) { |fragment, clauses| append_fragment(clauses, fragment) }
50
+ .flat_map { |clause| flatten_group(clause) }
51
+ end
52
+
53
+ # Starts a new clause, or hands the fragment to the previous clause when
54
+ # that one is still waiting for the rest of itself.
55
+ def append_fragment(clauses, fragment)
56
+ previous = clauses.pop if clauses.last && incomplete?(clauses.last)
57
+
58
+ clauses << [previous, fragment].compact.join(' AND ')
59
+ end
60
+
61
+ # A clause is incomplete while it holds an unclosed parenthesis, or a
62
+ # `BETWEEN` whose range is still missing the `AND` that ends it.
63
+ def incomplete?(clause)
64
+ Parentheses.unclosed?(clause) ||
65
+ clause.scan(/\bBETWEEN\b/i).length > clause.scan(/\bAND\b/i).length
66
+ end
67
+
68
+ # `a AND (b AND c)` means `a AND b AND c`, so a group whose top level is a
69
+ # conjunction is flattened into the surrounding clause list. An `OR`
70
+ # nested deeper inside it stays in its own group and is split around.
71
+ def flatten_group(clause)
72
+ inner = Parentheses.strip_outer(clause)
73
+ return [clause] if inner == clause || top_level_or?(inner)
74
+
75
+ split(inner)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DatabaseConsistency
4
+ module Helper
5
+ # Parenthesis bookkeeping for SQL predicates: what a pair of parentheses
6
+ # encloses, and whether a fragment is still waiting for one to close.
7
+ module Parentheses
8
+ module_function
9
+
10
+ # Repeatedly removes one wrapping layer when the whole fragment is
11
+ # enclosed, e.g. `((foo))` -> `foo`.
12
+ def strip_outer(sql)
13
+ stripped_sql = sql.strip
14
+
15
+ stripped_sql = stripped_sql[1..-2].strip while wrapping?(stripped_sql)
16
+
17
+ stripped_sql
18
+ end
19
+
20
+ # Returns true only when one outer pair encloses the whole string, not
21
+ # when that parenthesis closes earlier inside the expression.
22
+ def wrapping?(sql)
23
+ return false unless sql.start_with?('(') && sql.end_with?(')')
24
+
25
+ depth = 0
26
+
27
+ sql[1..-2].each_char do |char|
28
+ depth = depth_after(depth, char)
29
+ return false if depth.negative?
30
+ end
31
+
32
+ depth.zero?
33
+ end
34
+
35
+ # Returns true while the fragment opens a group it never closes.
36
+ def unclosed?(sql)
37
+ sql.count('(') > sql.count(')')
38
+ end
39
+
40
+ # Tracks nesting depth character by character.
41
+ def depth_after(depth, char)
42
+ case char
43
+ when '('
44
+ depth + 1
45
+ when ')'
46
+ depth - 1
47
+ else
48
+ depth
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -167,12 +167,10 @@ module DatabaseConsistency
167
167
  # `attribute IS NOT NULL` clause and never match the partial index.
168
168
  def uniqueness_validator_where_sql(model, attribute, validator)
169
169
  conditions_sql = conditions_where_sql(model, validator.options[:conditions])
170
- guard_sql = conditions_sql ? nil : uniqueness_validator_guard_sql(model, attribute, validator)
170
+ return conditions_sql.presence if conditions_sql
171
171
 
172
- sql_parts = [conditions_sql, guard_sql].reject { |part| part.nil? || part == '' }
173
- return nil if sql_parts.empty?
174
-
175
- normalize_condition_sql(sql_parts.join(' AND '))
172
+ guard_sql = uniqueness_validator_guard_sql(model, attribute, validator)
173
+ normalize_condition_sql(guard_sql) if guard_sql
176
174
  end
177
175
 
178
176
  # Returns true when validator conditions and index WHERE clause are a valid
@@ -199,6 +197,14 @@ module DatabaseConsistency
199
197
  # rather than the end of it.
200
198
  CONDITION_LITERAL = /'(?:[^']|'')*'/.freeze
201
199
 
200
+ # Matches a quoted identifier spelled like a keyword the normalizer reads,
201
+ # such as a column named `"or"`. Once its quotes were stripped it would read
202
+ # as the keyword, so it is renamed to an identifier-shaped placeholder
203
+ # instead and written back in double quotes at the end, which keeps the
204
+ # result the same when it is normalized again.
205
+ KEYWORD_IDENTIFIER = /["`](and|or|not|between|in|is|null|true|false)["`]/i.freeze
206
+ KEYWORD_IDENTIFIER_PLACEHOLDER = /__database_consistency_identifier_(\w+?)__/.freeze
207
+
202
208
  # Matches a masked literal, so steps that run on masked SQL can step over
203
209
  # the `<` and `>` in the placeholder.
204
210
  MASKED_LITERAL = Regexp.new(
@@ -321,6 +327,21 @@ module DatabaseConsistency
321
327
  \)? \s* \)
322
328
  /xi.freeze
323
329
 
330
+ # Matches one bound of a `BETWEEN` range: a number, a masked literal or a
331
+ # column.
332
+ BETWEEN_BOUND = /-?\d+(?:\.\d+)?(?:e[+-]?\d+)?|#{MASKED_LITERAL.source}|[a-z_][\w.]*/i.freeze
333
+
334
+ # Matches an inclusive range over a lone column, in the same three places
335
+ # as a bare boolean predicate, capturing the column and both bounds. A range
336
+ # over an expression, or with any other kind of bound, is left unmatched.
337
+ # A bare `NOT` is not one of the places, because it negates the whole range.
338
+ BETWEEN_RANGE = /
339
+ (^ | (?: \bAND\b | \bOR\b | (?<![\w.]) \( ))
340
+ \s* ([a-z_][\w.]*)
341
+ \s+ BETWEEN \s+ (#{BETWEEN_BOUND}) \s+ AND \s+ (#{BETWEEN_BOUND})
342
+ (?= \s* (?: $ | \bAND\b | \bOR\b | \) ))
343
+ /xi.freeze
344
+
324
345
  # Normalizes SQL predicates into a canonical form so semantically equivalent
325
346
  # Rails validators and database partial indexes can be compared safely.
326
347
  def normalize_condition_sql(sql)
@@ -333,7 +354,7 @@ module DatabaseConsistency
333
354
  .then { |value| mask_condition_literals(value) }
334
355
 
335
356
  normalize_masked_condition_sql(
336
- masked_sql.then { |value| strip_outer_parentheses(value) }
357
+ masked_sql.then { |value| Parentheses.strip_outer(value) }
337
358
  .then { |value| normalize_boolean_and_null_keywords(value) },
338
359
  literals
339
360
  )
@@ -344,9 +365,10 @@ module DatabaseConsistency
344
365
  # final structural clean-ups, and only then restores the literal values.
345
366
  # Restoring last protects literal contents from whitespace collapse and
346
367
  # clause sorting.
347
- def normalize_masked_condition_sql(masked_sql, literals)
368
+ def normalize_masked_condition_sql(masked_sql, literals) # rubocop:disable Metrics/AbcSize
348
369
  masked_sql
349
370
  .then { |value| normalize_adapter_syntax(value) }
371
+ .then { |value| expand_between_ranges(value) }
350
372
  .then { |value| normalize_boolean_predicates(value) }
351
373
  .then { |value| normalize_array_any_predicates(value) }
352
374
  .then { |value| normalize_negated_blank_or_nil_predicates(value) }
@@ -357,7 +379,8 @@ module DatabaseConsistency
357
379
 
358
380
  # Masks non-empty string literals so later regexes cannot rewrite their
359
381
  # contents. Empty literals are left untouched because negated-blank
360
- # normalization relies on them.
382
+ # normalization relies on them. A quoted identifier spelled like a keyword
383
+ # is masked too, as described at `KEYWORD_IDENTIFIER`.
361
384
  def mask_condition_literals(sql)
362
385
  literals = []
363
386
  masked_sql = sql.gsub(CONDITION_LITERAL) do |match|
@@ -368,16 +391,19 @@ module DatabaseConsistency
368
391
  format(LITERAL_PLACEHOLDER, index: literals.length - 1)
369
392
  end
370
393
  end
394
+ masked_sql = masked_sql.gsub(KEYWORD_IDENTIFIER) { "__database_consistency_identifier_#{Regexp.last_match(1)}__" }
371
395
  [masked_sql, literals]
372
396
  end
373
397
 
374
398
  # Restores literals in the order they were masked. Uses a block replacement
375
399
  # so backslashes inside the literal are not interpreted as regexp backrefs.
400
+ # A masked keyword identifier comes back in double quotes, whichever quote
401
+ # it was written with.
376
402
  def unmask_condition_literals(sql, literals)
377
403
  literals.each_with_index do |literal, index|
378
404
  sql = sql.sub(format(LITERAL_PLACEHOLDER, index: index)) { literal }
379
405
  end
380
- sql
406
+ sql.gsub(KEYWORD_IDENTIFIER_PLACEHOLDER, '"\1"')
381
407
  end
382
408
 
383
409
  # PostgreSQL writes any literal it had to coerce as a quoted string with a
@@ -515,40 +541,15 @@ module DatabaseConsistency
515
541
  normalized_sql
516
542
  end
517
543
 
518
- # Repeatedly removes one wrapping layer of parentheses when the whole SQL
519
- # fragment is enclosed, e.g. `((foo))` -> `foo`.
520
- def strip_outer_parentheses(sql)
521
- stripped_sql = sql.strip
544
+ # Writes an inclusive range out as the two comparisons PostgreSQL stores it
545
+ # as, so `qty BETWEEN 1 AND 10` lines up with `(qty >= 1) AND (qty <= 10)`.
546
+ # It runs before boolean predicates are read, so a bound that is a column
547
+ # is not mistaken for one.
548
+ def expand_between_ranges(sql)
549
+ sql.gsub(BETWEEN_RANGE) do
550
+ prefix, column, lower, upper = Regexp.last_match.captures
522
551
 
523
- stripped_sql = stripped_sql[1..-2].strip while wrapped_with_parentheses?(stripped_sql)
524
-
525
- stripped_sql
526
- end
527
-
528
- # Returns true only when the string is entirely wrapped by one outer pair of
529
- # parentheses, not when parentheses close earlier inside the expression.
530
- def wrapped_with_parentheses?(sql)
531
- return false unless sql.start_with?('(') && sql.end_with?(')')
532
-
533
- depth = 0
534
-
535
- sql[1..-2].each_char do |char|
536
- depth = parenthesis_depth(depth, char)
537
- return false if depth.negative?
538
- end
539
-
540
- depth.zero?
541
- end
542
-
543
- # Tracks parenthesis nesting depth character by character.
544
- def parenthesis_depth(depth, char)
545
- case char
546
- when '('
547
- depth + 1
548
- when ')'
549
- depth - 1
550
- else
551
- depth
552
+ "#{prefix} #{column} >= #{lower} AND #{column} <= #{upper}"
552
553
  end
553
554
  end
554
555
 
@@ -588,20 +589,18 @@ module DatabaseConsistency
588
589
  end
589
590
  end
590
591
 
591
- # Sorts simple `AND` clauses so `a AND b` and `b AND a` normalize to the
592
- # same string before comparison. Two clauses can be identical apart from the
592
+ # Sorts the `AND` clauses so `a AND b` and `b AND a` normalize to the same
593
+ # string before comparison. Two clauses can be identical apart from the
593
594
  # string each one compares against, and then those strings decide the order,
594
595
  # which is why the literals go back in before the sort. A placeholder is
595
596
  # numbered by where its literal appeared, so sorting on the placeholders
596
- # would leave such a pair in whichever order it arrived in.
597
+ # would leave such a pair in whichever order it arrived in. A column named
598
+ # like a keyword is ordered by its bare name, so its quotes do not move it
599
+ # ahead of every other clause.
597
600
  def sort_and_clauses(sql, literals)
598
- # Matches `AND` with surrounding whitespace and splits the expression into
599
- # comparable clause fragments.
600
- clauses = sql.split(/\s+AND\s+/i)
601
- return sql if clauses.length == 1
602
-
603
- clauses.map! { |clause| strip_outer_parentheses(clause) }
604
- clauses.sort_by { |clause| unmask_condition_literals(clause, literals) }.join(' AND ')
601
+ AndClauses.sort(sql) do |clause|
602
+ unmask_condition_literals(clause.gsub(KEYWORD_IDENTIFIER_PLACEHOLDER, '\1'), literals)
603
+ end
605
604
  end
606
605
 
607
606
  # Builds the implicit SQL guard introduced by validator options that skip
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DatabaseConsistency
4
- VERSION = '3.0.14'
4
+ VERSION = '3.0.16'
5
5
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  module DatabaseConsistency
4
6
  module Writers
5
7
  module Autofix
@@ -7,11 +9,21 @@ module DatabaseConsistency
7
9
  module Migration # :nodoc:
8
10
  def migration_path(name)
9
11
  last = migration_context.migrations.last
10
- version = ActiveRecord::Migration.next_migration_number(last&.version.to_i + 1)
12
+ version = ActiveRecord::Migration.next_migration_number(next_version_number(last&.version.to_i))
11
13
 
12
14
  "db/migrate/#{version}_#{name.underscore}.rb"
13
15
  end
14
16
 
17
+ # Adds one second to a timestamp version, so a batch of migrations keeps valid times.
18
+ def next_version_number(last_version)
19
+ return last_version + 1 unless last_version.to_s.match?(/\A\d{14}\z/)
20
+
21
+ time = Time.strptime("#{last_version} +0000", '%Y%m%d%H%M%S %z').utc
22
+ (time + 1).strftime('%Y%m%d%H%M%S').to_i
23
+ rescue ArgumentError
24
+ last_version + 1
25
+ end
26
+
15
27
  def migration_path_pattern(name)
16
28
  "db/migrate/*_#{name.underscore}.rb"
17
29
  end
@@ -8,19 +8,41 @@ module DatabaseConsistency
8
8
  {
9
9
  table_to_change: report.table_to_change,
10
10
  type_to_set: report.type_to_set,
11
- fk_name: report.fk_name
11
+ fk_name: report.fk_name,
12
+ change_columns: change_columns
12
13
  }
13
14
  end
14
15
 
15
16
  private
16
17
 
18
+ # Types such as "character varying" have characters that are not valid in a migration name.
17
19
  def migration_name
18
- "change_#{report.table_to_change}_#{report.fk_name}_to_#{report.type_to_set}"
20
+ "change_#{report.table_to_change}_#{fk_names.join('_')}_to_#{types.join('_')}".gsub(/\W+/, '_').chomp('_')
19
21
  end
20
22
 
21
23
  def template_path
22
24
  File.join(__dir__, 'templates', 'inconsistent_types.tt')
23
25
  end
26
+
27
+ # Symbol literals keep names such as "billing.orders" and "character varying" valid Ruby.
28
+ def change_columns
29
+ fk_names.zip(types).reject { |_fk_name, type| type.nil? }.map do |fk_name, type|
30
+ " change_column #{symbol(report.table_to_change)}, #{symbol(fk_name)}, #{symbol(type)}"
31
+ end.join("\n")
32
+ end
33
+
34
+ def symbol(name)
35
+ name.to_sym.inspect
36
+ end
37
+
38
+ # Composite keys are joined with "+" and have one type for each column.
39
+ def fk_names
40
+ report.fk_name.split('+')
41
+ end
42
+
43
+ def types
44
+ Array(report.type_to_set)
45
+ end
24
46
  end
25
47
  end
26
48
  end
@@ -1,5 +1,5 @@
1
1
  class %<migration_name>s < ActiveRecord::Migration[%<migration_version>s]
2
2
  def change
3
- change_column :%<table_to_change>s, :%<fk_name>s, :%<type_to_set>s
3
+ %<change_columns>s
4
4
  end
5
5
  end
@@ -4,6 +4,8 @@ require 'active_record'
4
4
 
5
5
  require 'database_consistency/version'
6
6
  require 'database_consistency/helper'
7
+ require 'database_consistency/helper/parentheses'
8
+ require 'database_consistency/helper/and_clauses'
7
9
  require 'database_consistency/files_helper'
8
10
  require 'database_consistency/prism_helper'
9
11
  require 'database_consistency/configuration'
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.14
4
+ version: 3.0.16
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-09-30 00:00:00.000000000 Z
11
+ date: 2026-10-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -186,6 +186,8 @@ files:
186
186
  - lib/database_consistency/errors.rb
187
187
  - lib/database_consistency/files_helper.rb
188
188
  - lib/database_consistency/helper.rb
189
+ - lib/database_consistency/helper/and_clauses.rb
190
+ - lib/database_consistency/helper/parentheses.rb
189
191
  - lib/database_consistency/prism_helper.rb
190
192
  - lib/database_consistency/processors/associations_processor.rb
191
193
  - lib/database_consistency/processors/base_processor.rb