database_consistency 3.0.14 → 3.0.15
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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d104523d7460faf27ac7523f032b1802b140560870685207fecaac76bcf76367
|
|
4
|
+
data.tar.gz: 955b8cc5a9217e578d4a2a2d36a198eca615b11c0ac7e060382904229950fd5c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 652477c0b3c936abbd3f443fad7de53331339a012d229939e61c204d3e60c03a55c3c5eb741aa5aaf5e4dd8d0ac03865e7a4ed80d58d37f85bf2e885eeaf163e
|
|
7
|
+
data.tar.gz: 657642ff01a583796083f8030a42cd0411b641ebe1fa624872f7467506815a15199493e4cc5765d300e58d65257f93a9cb8b8239a13302e46bffc87dc699124c
|
|
@@ -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
|
-
|
|
170
|
+
return conditions_sql.presence if conditions_sql
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
|
|
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|
|
|
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
|
-
#
|
|
519
|
-
#
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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
|
|
592
|
-
#
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
data/lib/database_consistency.rb
CHANGED
|
@@ -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.
|
|
4
|
+
version: 3.0.15
|
|
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-
|
|
11
|
+
date: 2026-10-08 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
|