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: 30c161f165c612d3196799c754ef057f0236c1bdcfd1bbd87d3c2fc0a2f28bc8
4
- data.tar.gz: 1196509cb852b3a5ded62f8866b19f34ab6d9abc80af89d0806b08a25b779b56
3
+ metadata.gz: d104523d7460faf27ac7523f032b1802b140560870685207fecaac76bcf76367
4
+ data.tar.gz: 955b8cc5a9217e578d4a2a2d36a198eca615b11c0ac7e060382904229950fd5c
5
5
  SHA512:
6
- metadata.gz: e4ba6f89c64740f04fa2a091fd5d137d4295b3884a9a24a524902e46ab20e80d5b7a76db77de59d1e8b214ef656724da103154169bbc99dada06eb6e1f150a26
7
- data.tar.gz: 618299c5d08832c05120596316c919526ee72b6646272b10dfa624d98a47021931a5f3baa439906305ec615cc3ed7e2f31e55c963891355ffe2fbf244b07c622
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
- 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,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
- WRAPPED_IDENTIFIER = /#{IN_LIST_OPENING}\(([a-z_][\w.]*)\)/i.freeze
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.]*) \s*
269
- (?= $ | (?: \bAND\b | \bOR\b | \) ))
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 form so that `NOT archived` is already
274
- # gone and cannot be read as the predicate `archived`.
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.]*) \s*
278
- (?= $ | (?: \bAND\b | \bOR\b | \) ))
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| strip_outer_parentheses(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, the parentheses PostgreSQL
448
- # adds around a cast operand and the `<>` it writes for inequality. Literals are masked throughout, so
449
- # none of it reaches the inside of a value.
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
- # `/\s*<>\s*/` rewrites the SQL inequality operator `<>` to `!=`.
462
- normalized_sql = normalized_sql.gsub(/\s*<>\s*/, ' != ')
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
- # Repeatedly removes one wrapping layer of parentheses when the whole SQL
481
- # fragment is enclosed, e.g. `((foo))` -> `foo`.
482
- def strip_outer_parentheses(sql)
483
- stripped_sql = sql.strip
484
-
485
- stripped_sql = stripped_sql[1..-2].strip while wrapped_with_parentheses?(stripped_sql)
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
- # Tracks parenthesis nesting depth character by character.
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 simple `AND` clauses so `a AND b` and `b AND a` normalize to the
554
- # 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
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
- # Matches `AND` with surrounding whitespace and splits the expression into
561
- # comparable clause fragments.
562
- clauses = sql.split(/\s+AND\s+/i)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DatabaseConsistency
4
- VERSION = '3.0.13'
4
+ VERSION = '3.0.15'
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.13
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-09-22 00:00:00.000000000 Z
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