database_consistency 3.0.11 → 3.0.13

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: af9d610b968b1e10caed92c35cd94d724a93e75af2bfa3b14ac3d9f5af4592d7
4
- data.tar.gz: a7d5e9c0fd7e1ca330da9104dc61a11f39d09b433b0296b2d63c16e0d72d9a56
3
+ metadata.gz: 30c161f165c612d3196799c754ef057f0236c1bdcfd1bbd87d3c2fc0a2f28bc8
4
+ data.tar.gz: 1196509cb852b3a5ded62f8866b19f34ab6d9abc80af89d0806b08a25b779b56
5
5
  SHA512:
6
- metadata.gz: 14fdf20cb9ec117660bf497981b548df0b16259f0206d89a62d088f63c87eca9f361dfc3ff3dde02b00af34ae2bc12bc6687f425a2f423b0b2d919e3a13f9796
7
- data.tar.gz: e23ea79b4d0883e5bc6117e7ebc02236f1ec47a6175725390b1c596c7b6fb509755d2285819faec994ccf5434fe2b5eaf8c31f74f0f89886e51f613fd8486225
6
+ metadata.gz: e4ba6f89c64740f04fa2a091fd5d137d4295b3884a9a24a524902e46ab20e80d5b7a76db77de59d1e8b214ef656724da103154169bbc99dada06eb6e1f150a26
7
+ data.tar.gz: 618299c5d08832c05120596316c919526ee72b6646272b10dfa624d98a47021931a5f3baa439906305ec615cc3ed7e2f31e55c963891355ffe2fbf244b07c622
@@ -151,7 +151,7 @@ module DatabaseConsistency
151
151
  where_part = sql.split(/\bWHERE\b/i, 2).last
152
152
  return nil unless where_part
153
153
 
154
- normalize_condition_sql(where_part.gsub("#{model.quoted_table_name}.", '').gsub('"', ''))
154
+ normalize_condition_sql(where_part.gsub("#{model.quoted_table_name}.", ''))
155
155
  rescue StandardError
156
156
  nil
157
157
  end
@@ -188,41 +188,293 @@ module DatabaseConsistency
188
188
  validator_where.casecmp?(normalized_where)
189
189
  end
190
190
 
191
+ # Prefix used to mask string literals while regex normalization runs, so
192
+ # patterns that strip casts or unwrap parentheses never see the inside of a
193
+ # literal value. Angle brackets are used so the placeholder cannot be mistaken
194
+ # for a column name by the boolean-predicate normalizer.
195
+ LITERAL_PLACEHOLDER = '__DATABASE_CONSISTENCY_LITERAL<%<index>d>__'
196
+
197
+ # Matches one single-quoted string literal, including any `''` it contains:
198
+ # SQL escapes a quote by doubling it, so a `''` pair is part of the value
199
+ # rather than the end of it.
200
+ CONDITION_LITERAL = /'(?:[^']|'')*'/.freeze
201
+
202
+ # Matches a number PostgreSQL had to quote in order to coerce it, together
203
+ # with the cast that says it is a number rather than a string. `::text` is
204
+ # deliberately absent from the list so a genuine string keeps its quotes.
205
+ COERCED_NUMERIC_LITERAL = /
206
+ ' (-? \d+ (?:\.\d+)? (?: e[+-]?\d+ )? ) '
207
+ (?= :: (?: integer | bigint | numeric | double\s+precision ) \b )
208
+ /xi.freeze
209
+
210
+ # Matches a PostgreSQL cast, covering the type names written as several
211
+ # words, the length or precision an explicit cast carries and the `[]` of an
212
+ # array type: `::text`, `::text[]`, `::double precision`,
213
+ # `::character varying(3)`, `::numeric(5,2)`, `::time without time zone`.
214
+ # A date or time type carries its precision in the middle of its name, as
215
+ # `::timestamp(0) without time zone`, so that branch spells out its own.
216
+ CONDITION_CAST = /
217
+ ::
218
+ (?:
219
+ character\s+varying |
220
+ double\s+precision |
221
+ bit\s+varying |
222
+ (?:timestamp|time) (?:\(\d+\))? \s+ (?:with|without)\s+time\s+zone |
223
+ \w+
224
+ )
225
+ (?:\(\d+(?:\s*,\s*\d+)?\))?
226
+ (?:\[\])?
227
+ /xi.freeze
228
+
229
+ # Matches a number written in exponent notation, capturing the sign, the
230
+ # digits on each side of the decimal point and the exponent separately so
231
+ # the point can be shifted through the digits as text. The lookbehind keeps
232
+ # the digits of an identifier such as `a1e5` out of it.
233
+ EXPONENT_LITERAL = /
234
+ (?<![\w.])
235
+ (-?) (\d+) (?: \.(\d+) )? e ([+-]?\d+)
236
+ /xi.freeze
237
+
238
+ # The parentheses right after `IN` or `NOT IN` are the list itself rather
239
+ # than something wrapped around a value, so the patterns below leave them
240
+ # alone and `qty IN (1)` stays a list of one. This covers only the
241
+ # parenthesis that opens the list; a value with parentheses of its own
242
+ # further along it, such as the `(1)` in `qty IN ((1), 2)`, still loses
243
+ # them.
244
+ IN_LIST_OPENING = /(?<!\bIN\s)/i.freeze
245
+
246
+ # Matches a bare identifier wrapped in parentheses, e.g. `(internal_name)`.
247
+ WRAPPED_IDENTIFIER = /#{IN_LIST_OPENING}\(([a-z_][\w.]*)\)/i.freeze
248
+
249
+ # Matches a parenthesized numeric literal, e.g. `(0)` or `(0.001)`, which is
250
+ # what a cast such as `(0)::numeric` leaves behind once the cast is gone.
251
+ # The lookbehind keeps the argument list of a call such as `abs(1)` intact.
252
+ WRAPPED_NUMBER = /(?<![\w.])#{IN_LIST_OPENING}\((-?\d+(?:\.\d+)?(?:e-?\d+)?)\)/.freeze
253
+
254
+ # Matches parentheses wrapping exactly one function call, such as the
255
+ # `(abs(1))` a removed `::numeric` cast leaves behind. The inner group
256
+ # recurses so the call's own argument list may nest, and the lookbehind
257
+ # keeps a call's own parentheses out of it.
258
+ WRAPPED_FUNCTION_CALL = /
259
+ (?<![\w.]) #{IN_LIST_OPENING}
260
+ \( (?<call>[a-z_][\w.]* (?<arguments>\( (?:[^()] | \g<arguments>)* \)) ) \)
261
+ /xi.freeze
262
+
263
+ # Matches a bare negated boolean predicate such as `NOT archived`, in the
264
+ # three places one can stand: at the start of an expression, after `AND` or
265
+ # `OR`, or after an opening parenthesis.
266
+ NEGATED_BOOLEAN_PREDICATE = /
267
+ (^ | (?: \bAND\b | \bOR\b | \( ))
268
+ \s* NOT \s+ ([a-z_][\w.]*) \s*
269
+ (?= $ | (?: \bAND\b | \bOR\b | \) ))
270
+ /xi.freeze
271
+
272
+ # 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`.
275
+ BARE_BOOLEAN_PREDICATE = /
276
+ (^ | (?: \bAND\b | \bOR\b | \( ))
277
+ \s* ([a-z_][\w.]*) \s*
278
+ (?= $ | (?: \bAND\b | \bOR\b | \) ))
279
+ /xi.freeze
280
+
281
+ # Matches `column = ANY (ARRAY[...])` or `column != ALL ((ARRAY[...]))`,
282
+ # capturing the column name, the operator and the array payload. The inner
283
+ # parentheses come from Postgres indexdefs that wrap the array expression
284
+ # before casting; they are optional, but both or neither, so a group
285
+ # enclosing the whole predicate keeps its own.
286
+ ARRAY_MEMBERSHIP_PREDICATE = /
287
+ (?<column>[a-z_][\w.]*)\s*
288
+ (?<operator>=\s*ANY|(?:!=|<>)\s*ALL)\s*
289
+ \( (?: \(ARRAY\[(?<items>.*?)\]\) | ARRAY\[(?<items>.*?)\] ) \)
290
+ /xi.freeze
291
+
292
+ # Matches SQL like `NOT (column = '' OR column IS NULL)`, holding both sides
293
+ # to the same column with the backreference.
294
+ NEGATED_BLANK_OR_NIL_PREDICATE = /
295
+ NOT \s+ \( \s* \(?
296
+ ([a-z_][\w.]*) \s* = \s* '' \s+ OR \s+ \1 \s+ IS \s+ NULL
297
+ \)? \s* \)
298
+ /xi.freeze
299
+
191
300
  # Normalizes SQL predicates into a canonical form so semantically equivalent
192
301
  # Rails validators and database partial indexes can be compared safely.
193
302
  def normalize_condition_sql(sql)
194
- sql
195
- .to_s
196
- .then { |value| strip_outer_parentheses(value) }
197
- .then { |value| normalize_sql(value) }
303
+ # The two steps that read the inside of a literal run first, while it is
304
+ # still there to read. Everything after masking works on the shape of the
305
+ # predicate alone and so cannot rewrite a value by accident.
306
+ masked_sql, literals = sql.to_s
307
+ .then { |value| unquote_numeric_literals(value) }
308
+ .then { |value| normalize_quoted_boolean_literals(value) }
309
+ .then { |value| mask_condition_literals(value) }
310
+
311
+ normalize_masked_condition_sql(
312
+ masked_sql.then { |value| strip_outer_parentheses(value) }
313
+ .then { |value| normalize_boolean_and_null_keywords(value) },
314
+ literals
315
+ )
316
+ end
317
+
318
+ # Finishes normalization after string literals have been masked: runs the
319
+ # regex-based transforms that must not see inside literals, applies the
320
+ # final structural clean-ups, and only then restores the literal values.
321
+ # Restoring last protects literal contents from whitespace collapse and
322
+ # clause sorting.
323
+ def normalize_masked_condition_sql(masked_sql, literals)
324
+ masked_sql
325
+ .then { |value| normalize_adapter_syntax(value) }
198
326
  .then { |value| normalize_boolean_predicates(value) }
199
327
  .then { |value| normalize_array_any_predicates(value) }
200
328
  .then { |value| normalize_negated_blank_or_nil_predicates(value) }
201
- .then { |value| sort_and_clauses(value) }
329
+ .then { |value| sort_and_clauses(value, literals) }
330
+ .then { |value| value.gsub(/\s+/, ' ').strip }
331
+ .then { |value| unmask_condition_literals(value, literals) }
332
+ end
333
+
334
+ # Masks non-empty string literals so later regexes cannot rewrite their
335
+ # contents. Empty literals are left untouched because negated-blank
336
+ # normalization relies on them.
337
+ def mask_condition_literals(sql)
338
+ literals = []
339
+ masked_sql = sql.gsub(CONDITION_LITERAL) do |match|
340
+ if match == "''"
341
+ match
342
+ else
343
+ literals << match
344
+ format(LITERAL_PLACEHOLDER, index: literals.length - 1)
345
+ end
346
+ end
347
+ [masked_sql, literals]
348
+ end
349
+
350
+ # Restores literals in the order they were masked. Uses a block replacement
351
+ # so backslashes inside the literal are not interpreted as regexp backrefs.
352
+ def unmask_condition_literals(sql, literals)
353
+ literals.each_with_index do |literal, index|
354
+ sql = sql.sub(format(LITERAL_PLACEHOLDER, index: index)) { literal }
355
+ end
356
+ sql
202
357
  end
203
358
 
204
- # Applies lightweight SQL normalization without changing the logical meaning.
205
- def normalize_sql(sql)
206
- # `/::\w+/` removes PostgreSQL casts like `column::text`.
207
- normalized_sql = sql.gsub(/::\w+/, '')
208
- # `/\(([a-z_][\w.]*)\)/i` unwraps a bare identifier surrounded by
209
- # parentheses, e.g. `(internal_name)` -> `internal_name`.
210
- normalized_sql = normalized_sql.gsub(/\(([a-z_][\w.]*)\)/i, '\1')
359
+ # PostgreSQL writes any literal it had to coerce as a quoted string with a
360
+ # cast: `-1` becomes `'-1'::integer`, `-1.5` becomes `'-1.5'::numeric` and
361
+ # `1e+20` becomes `'1e+20'::double precision`. Unquoting those lets them line
362
+ # up with the bare numbers Active Record generates. A `::text` cast is left
363
+ # alone so a genuine string comparison keeps its quotes.
364
+ def unquote_numeric_literals(sql)
365
+ sql.gsub(COERCED_NUMERIC_LITERAL) { Regexp.last_match(1) }
366
+ end
367
+
368
+ # Rewrites a boolean written as the quoted `'t'` / `'f'` PostgreSQL stores.
369
+ # It reads the value inside the quotes, so it has to run before literals are
370
+ # masked, while that value is still there to read.
371
+ def normalize_quoted_boolean_literals(sql)
372
+ # Normalize PostgreSQL boolean literals stored as `'t'` / `'f'` inside
373
+ # comparisons. The operator is allowed to touch or be surrounded by
374
+ # arbitrary whitespace so forms like `flag='t'` and `flag <> 'f'` all
375
+ # collapse to the same canonical shape. Inequality is preserved as `!=`
376
+ # because `flag <> 't'` is not the same as `flag = 'f'` (NULL handling
377
+ # differs), so they must not share a canonical form. The lookbehind holds
378
+ # the equality patterns to a standalone `=`, so the ordering comparison in
379
+ # `note >= 't'` keeps both its operator and its value.
380
+ sql
381
+ .gsub(/(?<![<>!])\s*=\s*'t'/, ' = 1')
382
+ .gsub(/(?<![<>!])\s*=\s*'f'/, ' = 0')
383
+ .gsub(/\s*<>\s*'t'/, ' != 1')
384
+ .gsub(/\s*<>\s*'f'/, ' != 0')
385
+ .gsub(/\s*!=\s*'t'/, ' != 1')
386
+ .gsub(/\s*!=\s*'f'/, ' != 0')
387
+ end
388
+
389
+ # Rewrites the `TRUE` / `FALSE` / `NULL` keywords and the `IS` phrasings
390
+ # around them to one spelling. These run once literals are masked, so a
391
+ # value that happens to read `IS TRUE` keeps its own text.
392
+ def normalize_boolean_and_null_keywords(sql)
393
+ normalized_sql = sql.dup
394
+ # `IS NOT TRUE` / `IS NOT FALSE` are matched before the bare `IS TRUE` /
395
+ # `IS FALSE` forms so the longer phrase wins. They normalize to `IS NOT 1`
396
+ # / `IS NOT 0` rather than `= 0` / `= 1` because `IS NOT TRUE` is not the
397
+ # same as `= FALSE` (NULL handling differs).
398
+ normalized_sql = normalized_sql.gsub(/\bIS\s+NOT\s+TRUE\b/i, ' IS NOT 1')
399
+ normalized_sql = normalized_sql.gsub(/\bIS\s+NOT\s+FALSE\b/i, ' IS NOT 0')
400
+ # `/\bIS\s+TRUE\b/i` and `/\bIS\s+FALSE\b/i` normalize predicate forms
401
+ # like `flag IS TRUE` to `flag = 1` so they match `flag = TRUE` and
402
+ # `flag = 't'`.
403
+ normalized_sql = normalized_sql.gsub(/\bIS\s+TRUE\b/i, ' = 1')
404
+ normalized_sql = normalized_sql.gsub(/\bIS\s+FALSE\b/i, ' = 0')
211
405
  # `/\bTRUE\b/i` and `/\bFALSE\b/i` normalize boolean literals to `1` / `0`
212
406
  # so they match SQL generated by Active Record on some adapters.
213
407
  normalized_sql = normalized_sql.gsub(/\bTRUE\b/i, '1').gsub(/\bFALSE\b/i, '0')
214
- # `/\s*<>\s*/` rewrites the SQL inequality operator `<>` to `!=`.
215
- normalized_sql = normalized_sql.gsub(/\s*<>\s*/, ' != ')
216
408
  # `/\bIS\s+NOT\s+NULL\b/i` normalizes `IS NOT NULL` spacing and casing.
217
409
  normalized_sql = normalized_sql.gsub(/\bIS\s+NOT\s+NULL\b/i, ' IS NOT NULL')
218
410
  # `/\bIS\s+NULL\b/i` normalizes `IS NULL` spacing and casing.
219
411
  normalized_sql = normalized_sql.gsub(/\bIS\s+NULL\b/i, ' IS NULL')
220
- # `/ = 't'/` and `/ = 'f'/` normalize PostgreSQL boolean literals stored
221
- # as `'t'` / `'f'` inside comparisons.
222
- normalized_sql = normalized_sql.gsub(/ = 't'/, ' = 1').gsub(/ = 'f'/, ' = 0')
223
- # `/\s+/` collapses any run of whitespace to a single space.
224
- normalized_sql = normalized_sql.gsub(/\s+/, ' ')
225
- normalized_sql.strip
412
+ normalized_sql.gsub(/\s+/, ' ').strip
413
+ end
414
+
415
+ # Rewrites exponent notation as the plain decimal PostgreSQL itself writes
416
+ # when it expands a literal, so `1e+20` and the `1.0e+20` Active Record
417
+ # generates reach the same string. The digits are shifted as text rather
418
+ # than through a float, so a wide value keeps every one of them.
419
+ def expand_exponent_literals(sql)
420
+ sql.gsub(EXPONENT_LITERAL) do
421
+ match = Regexp.last_match
422
+ shift_decimal_point(match[1], "#{match[2]}#{match[3]}", match[2].length + match[4].to_i)
423
+ end
424
+ end
425
+
426
+ # Places the decimal point `position` digits into `digits`, padding with
427
+ # zeros on whichever side falls short and dropping a fraction that ends in
428
+ # them, so `1e-20` and `1.0e-20` land on the same digits. A zero that only
429
+ # holds the decimal point's place goes too, so `0.1e+2` reaches `10`.
430
+ def shift_decimal_point(sign, digits, position)
431
+ expanded =
432
+ if position >= digits.length
433
+ digits + ('0' * (position - digits.length))
434
+ elsif position.positive?
435
+ "#{digits[0...position]}.#{digits[position..]}"
436
+ else
437
+ "0.#{'0' * -position}#{digits}"
438
+ end
439
+ # On Ruby < 3.0, frozen strings forbid `sub!`.
440
+ expanded = expanded.sub(/\A0+(?=\d)/, '')
441
+
442
+ "#{sign}#{expanded}".sub(/(\.\d*?)0+\z/, '\1').chomp('.')
443
+ end
444
+
445
+ # Rewrites the spellings that differ between adapters, or between what an
446
+ # 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.
450
+ def normalize_adapter_syntax(sql)
451
+ # Strips quoted identifiers (double quotes on PostgreSQL/SQLite,
452
+ # backticks on MySQL) so the same column normalizes across adapters.
453
+ normalized_sql = sql.gsub(/["`]/, '')
454
+ normalized_sql = normalized_sql.gsub(CONDITION_CAST, '')
455
+ normalized_sql = expand_exponent_literals(normalized_sql)
456
+ # Gives `IN` one space before its list, so `qty IN(1)` and `qty IN (1)`
457
+ # reach the same string and the list is recognisable to the unwrappers
458
+ # below. `\b` keeps a call such as `min(1)` out of it.
459
+ normalized_sql = normalized_sql.gsub(/\bIN\s*\(/i, 'IN (')
460
+ normalized_sql = unwrap_redundant_parentheses(normalized_sql)
461
+ # `/\s*<>\s*/` rewrites the SQL inequality operator `<>` to `!=`.
462
+ normalized_sql = normalized_sql.gsub(/\s*<>\s*/, ' != ')
463
+ normalized_sql.gsub(/\s+/, ' ').strip
464
+ end
465
+
466
+ # Removes the parentheses PostgreSQL puts around an operand it had to cast,
467
+ # which are redundant once the cast itself is gone: `(0)::numeric` -> `0`,
468
+ # `((name)::character varying(3))::text` -> `name`, `(abs(1))::numeric` ->
469
+ # `abs(1)`. Each pass repeats because removing one layer can expose another.
470
+ def unwrap_redundant_parentheses(sql)
471
+ normalized_sql = sql.dup
472
+
473
+ true while normalized_sql.gsub!(WRAPPED_IDENTIFIER, '\1')
474
+ true while normalized_sql.gsub!(WRAPPED_NUMBER, '\1')
475
+ true while normalized_sql.gsub!(WRAPPED_FUNCTION_CALL, '\k<call>')
476
+
477
+ normalized_sql
226
478
  end
227
479
 
228
480
  # Repeatedly removes one wrapping layer of parentheses when the whole SQL
@@ -267,52 +519,51 @@ module DatabaseConsistency
267
519
  def normalize_boolean_predicates(sql)
268
520
  normalized_sql = sql.dup
269
521
 
270
- # Matches a bare negated boolean predicate such as `NOT archived`
271
- # appearing at the start of an expression, after `AND` / `OR`, or after
272
- # an opening parenthesis, and rewrites it to `archived = 0`.
273
- normalized_sql.gsub!(
274
- /(^|(?:\bAND\b|\bOR\b|\())\s*NOT\s+([a-z_][\w.]*)\s*(?=$|(?:\bAND\b|\bOR\b|\)))/i
275
- ) { "#{Regexp.last_match(1)} #{Regexp.last_match(2)} = 0" }
522
+ normalized_sql.gsub!(NEGATED_BOOLEAN_PREDICATE) do
523
+ "#{Regexp.last_match(1)} #{Regexp.last_match(2)} = 0"
524
+ end
276
525
 
277
- # Matches a bare boolean predicate such as `most_recent` appearing in the
278
- # same structural positions, and rewrites it to `most_recent = 1`.
279
- normalized_sql.gsub!(
280
- /(^|(?:\bAND\b|\bOR\b|\())\s*([a-z_][\w.]*)\s*(?=$|(?:\bAND\b|\bOR\b|\)))/i
281
- ) { "#{Regexp.last_match(1)} #{Regexp.last_match(2)} = 1" }
526
+ normalized_sql.gsub!(BARE_BOOLEAN_PREDICATE) do
527
+ "#{Regexp.last_match(1)} #{Regexp.last_match(2)} = 1"
528
+ end
282
529
 
283
530
  normalized_sql.gsub(/\s+/, ' ').strip
284
531
  end
285
532
 
286
- # Rewrites PostgreSQL's `= ANY (ARRAY[...])` form into an `IN (...)` form
287
- # so it matches the SQL Active Record typically generates for arrays.
533
+ # Rewrites PostgreSQL's `= ANY (ARRAY[...])` and `<> ALL (ARRAY[...])` forms
534
+ # into the `IN (...)` and `NOT IN (...)` Active Record generates for arrays.
535
+ # `<>` has already become `!=` by this point in the pipeline.
288
536
  def normalize_array_any_predicates(sql)
289
- sql.gsub(
290
- # Matches `column = ANY (ARRAY[...])`, capturing the column name and the
291
- # full array payload so it can be converted to `column IN (...)`.
292
- /([a-z_][\w.]*)\s*=\s*ANY\s*\(ARRAY\[(.*?)\]\)/i
293
- ) { "#{Regexp.last_match(1)} IN (#{Regexp.last_match(2).gsub(/\s+/, ' ').strip})" }
537
+ sql.gsub(ARRAY_MEMBERSHIP_PREDICATE) do
538
+ match = Regexp.last_match
539
+ membership = match[:operator].match?(/ANY/i) ? 'IN' : 'NOT IN'
540
+
541
+ "#{match[:column]} #{membership} (#{match[:items].gsub(/\s+/, ' ').strip})"
542
+ end
294
543
  end
295
544
 
296
545
  # Rewrites negated "blank or nil" predicates into the same shape used by
297
546
  # `allow_blank`-derived guards: `IS NOT NULL AND != ''`.
298
547
  def normalize_negated_blank_or_nil_predicates(sql)
299
- sql.gsub(
300
- # Matches SQL like `NOT (column = '' OR column IS NULL)` while enforcing
301
- # the same column name on both sides via backreference `\1`.
302
- /NOT\s+\(\s*\(?([a-z_][\w.]*)\s*=\s*''\s+OR\s+\1\s+IS\s+NULL\)?\s*\)/i
303
- ) { "#{Regexp.last_match(1)} IS NOT NULL AND #{Regexp.last_match(1)} != ''" }
548
+ sql.gsub(NEGATED_BLANK_OR_NIL_PREDICATE) do
549
+ "#{Regexp.last_match(1)} IS NOT NULL AND #{Regexp.last_match(1)} != ''"
550
+ end
304
551
  end
305
552
 
306
553
  # Sorts simple `AND` clauses so `a AND b` and `b AND a` normalize to the
307
- # same string before comparison.
308
- def sort_and_clauses(sql)
554
+ # same string before comparison. Two clauses can be identical apart from the
555
+ # string each one compares against, and then those strings decide the order,
556
+ # which is why the literals go back in before the sort. A placeholder is
557
+ # numbered by where its literal appeared, so sorting on the placeholders
558
+ # would leave such a pair in whichever order it arrived in.
559
+ def sort_and_clauses(sql, literals)
309
560
  # Matches `AND` with surrounding whitespace and splits the expression into
310
561
  # comparable clause fragments.
311
562
  clauses = sql.split(/\s+AND\s+/i)
312
563
  return sql if clauses.length == 1
313
564
 
314
565
  clauses.map! { |clause| strip_outer_parentheses(clause) }
315
- clauses.sort.join(' AND ')
566
+ clauses.sort_by { |clause| unmask_condition_literals(clause, literals) }.join(' AND ')
316
567
  end
317
568
 
318
569
  # 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.11'
4
+ VERSION = '3.0.13'
5
5
  end
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.11
4
+ version: 3.0.13
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-08-24 00:00:00.000000000 Z
11
+ date: 2026-09-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord