database_consistency 3.0.13 → 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,30 @@ 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
|
+
|
|
208
|
+
# Matches a masked literal, so steps that run on masked SQL can step over
|
|
209
|
+
# the `<` and `>` in the placeholder.
|
|
210
|
+
MASKED_LITERAL = Regexp.new(
|
|
211
|
+
Regexp.escape(LITERAL_PLACEHOLDER).sub('%<index>d') { '\d+' }
|
|
212
|
+
).freeze
|
|
213
|
+
|
|
214
|
+
# Matches an operator together with whatever spaces were written around it.
|
|
215
|
+
# `+` and `-` are left out: either can be a sign as well as an operator, and
|
|
216
|
+
# telling the two apart takes a parser. `->` and `->>` are the exception,
|
|
217
|
+
# since neither can be read as a sign.
|
|
218
|
+
CONDITION_OPERATOR = %r{\s*(->>?|[<>=!~@#%^&|?*/]+)\s*}.freeze
|
|
219
|
+
|
|
220
|
+
# Matches either of the two, so the spacing step can find operators while
|
|
221
|
+
# passing over the placeholders.
|
|
222
|
+
MASKED_LITERAL_OR_OPERATOR = Regexp.union(MASKED_LITERAL, CONDITION_OPERATOR).freeze
|
|
223
|
+
|
|
202
224
|
# Matches a number PostgreSQL had to quote in order to coerce it, together
|
|
203
225
|
# with the cast that says it is a number rather than a string. `::text` is
|
|
204
226
|
# deliberately absent from the list so a genuine string keeps its quotes.
|
|
@@ -244,7 +266,9 @@ module DatabaseConsistency
|
|
|
244
266
|
IN_LIST_OPENING = /(?<!\bIN\s)/i.freeze
|
|
245
267
|
|
|
246
268
|
# Matches a bare identifier wrapped in parentheses, e.g. `(internal_name)`.
|
|
247
|
-
|
|
269
|
+
# The lookbehind keeps the argument list of a call such as `lower(name)`
|
|
270
|
+
# intact.
|
|
271
|
+
WRAPPED_IDENTIFIER = /(?<![\w.])#{IN_LIST_OPENING}\(([a-z_][\w.]*)\)/i.freeze
|
|
248
272
|
|
|
249
273
|
# Matches a parenthesized numeric literal, e.g. `(0)` or `(0.001)`, which is
|
|
250
274
|
# what a cast such as `(0)::numeric` leaves behind once the cast is gone.
|
|
@@ -262,20 +286,26 @@ module DatabaseConsistency
|
|
|
262
286
|
|
|
263
287
|
# Matches a bare negated boolean predicate such as `NOT archived`, in the
|
|
264
288
|
# three places one can stand: at the start of an expression, after `AND` or
|
|
265
|
-
# `OR`, or after an opening parenthesis.
|
|
289
|
+
# `OR`, or after an opening parenthesis. The whitespace before whatever
|
|
290
|
+
# follows sits inside the lookahead, so the match leaves it in place instead
|
|
291
|
+
# of consuming it and fusing the next `AND` / `OR` to the rewritten
|
|
292
|
+
# predicate. The lookbehind keeps a call's own parenthesis out of the
|
|
293
|
+
# boolean positions, so the argument of `lower(...)` is not read as a
|
|
294
|
+
# predicate of its own.
|
|
266
295
|
NEGATED_BOOLEAN_PREDICATE = /
|
|
267
|
-
(^ | (?: \bAND\b | \bOR\b | \( ))
|
|
268
|
-
\s* NOT \s+ ([a-z_][\w.]*)
|
|
269
|
-
(?= $ |
|
|
296
|
+
(^ | (?: \bAND\b | \bOR\b | (?<![\w.]) \( ))
|
|
297
|
+
\s* NOT \s+ ([a-z_][\w.]*)
|
|
298
|
+
(?= \s* (?: $ | \bAND\b | \bOR\b | \) ))
|
|
270
299
|
/xi.freeze
|
|
271
300
|
|
|
272
301
|
# Matches a bare boolean predicate such as `most_recent` in those same three
|
|
273
|
-
# places. It runs after the negated
|
|
274
|
-
# gone and cannot be read as the
|
|
302
|
+
# places, with the same lookahead and lookbehind. It runs after the negated
|
|
303
|
+
# form so that `NOT archived` is already gone and cannot be read as the
|
|
304
|
+
# predicate `archived`.
|
|
275
305
|
BARE_BOOLEAN_PREDICATE = /
|
|
276
|
-
(^ | (?: \bAND\b | \bOR\b | \( ))
|
|
277
|
-
\s* ([a-z_][\w.]*)
|
|
278
|
-
(?= $ |
|
|
306
|
+
(^ | (?: \bAND\b | \bOR\b | (?<![\w.]) \( ))
|
|
307
|
+
\s* ([a-z_][\w.]*)
|
|
308
|
+
(?= \s* (?: $ | \bAND\b | \bOR\b | \) ))
|
|
279
309
|
/xi.freeze
|
|
280
310
|
|
|
281
311
|
# Matches `column = ANY (ARRAY[...])` or `column != ALL ((ARRAY[...]))`,
|
|
@@ -297,6 +327,21 @@ module DatabaseConsistency
|
|
|
297
327
|
\)? \s* \)
|
|
298
328
|
/xi.freeze
|
|
299
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
|
+
|
|
300
345
|
# Normalizes SQL predicates into a canonical form so semantically equivalent
|
|
301
346
|
# Rails validators and database partial indexes can be compared safely.
|
|
302
347
|
def normalize_condition_sql(sql)
|
|
@@ -309,7 +354,7 @@ module DatabaseConsistency
|
|
|
309
354
|
.then { |value| mask_condition_literals(value) }
|
|
310
355
|
|
|
311
356
|
normalize_masked_condition_sql(
|
|
312
|
-
masked_sql.then { |value|
|
|
357
|
+
masked_sql.then { |value| Parentheses.strip_outer(value) }
|
|
313
358
|
.then { |value| normalize_boolean_and_null_keywords(value) },
|
|
314
359
|
literals
|
|
315
360
|
)
|
|
@@ -320,9 +365,10 @@ module DatabaseConsistency
|
|
|
320
365
|
# final structural clean-ups, and only then restores the literal values.
|
|
321
366
|
# Restoring last protects literal contents from whitespace collapse and
|
|
322
367
|
# clause sorting.
|
|
323
|
-
def normalize_masked_condition_sql(masked_sql, literals)
|
|
368
|
+
def normalize_masked_condition_sql(masked_sql, literals) # rubocop:disable Metrics/AbcSize
|
|
324
369
|
masked_sql
|
|
325
370
|
.then { |value| normalize_adapter_syntax(value) }
|
|
371
|
+
.then { |value| expand_between_ranges(value) }
|
|
326
372
|
.then { |value| normalize_boolean_predicates(value) }
|
|
327
373
|
.then { |value| normalize_array_any_predicates(value) }
|
|
328
374
|
.then { |value| normalize_negated_blank_or_nil_predicates(value) }
|
|
@@ -333,7 +379,8 @@ module DatabaseConsistency
|
|
|
333
379
|
|
|
334
380
|
# Masks non-empty string literals so later regexes cannot rewrite their
|
|
335
381
|
# contents. Empty literals are left untouched because negated-blank
|
|
336
|
-
# 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`.
|
|
337
384
|
def mask_condition_literals(sql)
|
|
338
385
|
literals = []
|
|
339
386
|
masked_sql = sql.gsub(CONDITION_LITERAL) do |match|
|
|
@@ -344,16 +391,19 @@ module DatabaseConsistency
|
|
|
344
391
|
format(LITERAL_PLACEHOLDER, index: literals.length - 1)
|
|
345
392
|
end
|
|
346
393
|
end
|
|
394
|
+
masked_sql = masked_sql.gsub(KEYWORD_IDENTIFIER) { "__database_consistency_identifier_#{Regexp.last_match(1)}__" }
|
|
347
395
|
[masked_sql, literals]
|
|
348
396
|
end
|
|
349
397
|
|
|
350
398
|
# Restores literals in the order they were masked. Uses a block replacement
|
|
351
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.
|
|
352
402
|
def unmask_condition_literals(sql, literals)
|
|
353
403
|
literals.each_with_index do |literal, index|
|
|
354
404
|
sql = sql.sub(format(LITERAL_PLACEHOLDER, index: index)) { literal }
|
|
355
405
|
end
|
|
356
|
-
sql
|
|
406
|
+
sql.gsub(KEYWORD_IDENTIFIER_PLACEHOLDER, '"\1"')
|
|
357
407
|
end
|
|
358
408
|
|
|
359
409
|
# PostgreSQL writes any literal it had to coerce as a quoted string with a
|
|
@@ -442,11 +492,23 @@ module DatabaseConsistency
|
|
|
442
492
|
"#{sign}#{expanded}".sub(/(\.\d*?)0+\z/, '\1').chomp('.')
|
|
443
493
|
end
|
|
444
494
|
|
|
495
|
+
# Gives every operator a space either side, every comma a space after it
|
|
496
|
+
# and no parenthesis a space on its inside, which is how PostgreSQL writes
|
|
497
|
+
# an indexdef however the index was typed.
|
|
498
|
+
def normalize_operator_spacing(sql)
|
|
499
|
+
spaced_sql = sql.gsub(MASKED_LITERAL_OR_OPERATOR) do |match|
|
|
500
|
+
match.match?(MASKED_LITERAL) ? match : " #{match.strip} "
|
|
501
|
+
end
|
|
502
|
+
spaced_sql = spaced_sql.gsub(/\s*,\s*/, ', ')
|
|
503
|
+
spaced_sql.gsub(/\(\s+/, '(').gsub(/\s+\)/, ')')
|
|
504
|
+
end
|
|
505
|
+
|
|
445
506
|
# Rewrites the spellings that differ between adapters, or between what an
|
|
446
507
|
# adapter stores and what Active Record writes: quoted identifiers, casts,
|
|
447
|
-
# exponent notation, the spacing of an `IN` list,
|
|
448
|
-
# adds around a cast operand and the `<>`
|
|
449
|
-
#
|
|
508
|
+
# exponent notation, the spacing of an `IN` list, of operators and of
|
|
509
|
+
# commas, the parentheses PostgreSQL adds around a cast operand and the `<>`
|
|
510
|
+
# it writes for inequality. Literals are masked throughout, so none of it
|
|
511
|
+
# reaches the inside of a value.
|
|
450
512
|
def normalize_adapter_syntax(sql)
|
|
451
513
|
# Strips quoted identifiers (double quotes on PostgreSQL/SQLite,
|
|
452
514
|
# backticks on MySQL) so the same column normalizes across adapters.
|
|
@@ -457,9 +519,11 @@ module DatabaseConsistency
|
|
|
457
519
|
# reach the same string and the list is recognisable to the unwrappers
|
|
458
520
|
# below. `\b` keeps a call such as `min(1)` out of it.
|
|
459
521
|
normalized_sql = normalized_sql.gsub(/\bIN\s*\(/i, 'IN (')
|
|
522
|
+
normalized_sql = normalize_operator_spacing(normalized_sql)
|
|
460
523
|
normalized_sql = unwrap_redundant_parentheses(normalized_sql)
|
|
461
|
-
#
|
|
462
|
-
|
|
524
|
+
# Rewrites the SQL inequality operator `<>` to `!=`; the spacing step
|
|
525
|
+
# has already given it a space either side.
|
|
526
|
+
normalized_sql = normalized_sql.gsub('<>', '!=')
|
|
463
527
|
normalized_sql.gsub(/\s+/, ' ').strip
|
|
464
528
|
end
|
|
465
529
|
|
|
@@ -477,40 +541,15 @@ module DatabaseConsistency
|
|
|
477
541
|
normalized_sql
|
|
478
542
|
end
|
|
479
543
|
|
|
480
|
-
#
|
|
481
|
-
#
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
stripped_sql
|
|
488
|
-
end
|
|
489
|
-
|
|
490
|
-
# Returns true only when the string is entirely wrapped by one outer pair of
|
|
491
|
-
# parentheses, not when parentheses close earlier inside the expression.
|
|
492
|
-
def wrapped_with_parentheses?(sql)
|
|
493
|
-
return false unless sql.start_with?('(') && sql.end_with?(')')
|
|
494
|
-
|
|
495
|
-
depth = 0
|
|
496
|
-
|
|
497
|
-
sql[1..-2].each_char do |char|
|
|
498
|
-
depth = parenthesis_depth(depth, char)
|
|
499
|
-
return false if depth.negative?
|
|
500
|
-
end
|
|
501
|
-
|
|
502
|
-
depth.zero?
|
|
503
|
-
end
|
|
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
|
|
504
551
|
|
|
505
|
-
|
|
506
|
-
def parenthesis_depth(depth, char)
|
|
507
|
-
case char
|
|
508
|
-
when '('
|
|
509
|
-
depth + 1
|
|
510
|
-
when ')'
|
|
511
|
-
depth - 1
|
|
512
|
-
else
|
|
513
|
-
depth
|
|
552
|
+
"#{prefix} #{column} >= #{lower} AND #{column} <= #{upper}"
|
|
514
553
|
end
|
|
515
554
|
end
|
|
516
555
|
|
|
@@ -550,20 +589,18 @@ module DatabaseConsistency
|
|
|
550
589
|
end
|
|
551
590
|
end
|
|
552
591
|
|
|
553
|
-
# Sorts
|
|
554
|
-
#
|
|
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
|
|
555
594
|
# string each one compares against, and then those strings decide the order,
|
|
556
595
|
# which is why the literals go back in before the sort. A placeholder is
|
|
557
596
|
# numbered by where its literal appeared, so sorting on the placeholders
|
|
558
|
-
# 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.
|
|
559
600
|
def sort_and_clauses(sql, literals)
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
return sql if clauses.length == 1
|
|
564
|
-
|
|
565
|
-
clauses.map! { |clause| strip_outer_parentheses(clause) }
|
|
566
|
-
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
|
|
567
604
|
end
|
|
568
605
|
|
|
569
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
|